diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 1705b22f..fe540f6b 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -698,14 +698,14 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected }); test('filter hidden save resets shelf cursor and measured heights retaining anchor',async({page})=>{ - await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Standalone'); + await page.goto(`${workspace}#/library`);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); const shelfReads=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library'&&item.method()==='GET')shelfReads.push(url.searchParams);}); await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); await field(dialog,'Title').fill('Hidden by filter');await field(dialog,'Positive prompt').fill('Lighthouse in heavy rain');await button(dialog,'Save').click(); await expect(page).toHaveURL(/#\/library\/\d+$/);await expect(page.locator('#workspace-main h1')).toHaveText('Hidden by filter'); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Standalone'); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Standalone'); await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); await expect(page.getByText('The saved prompt is hidden by the current filters.',{exact:false})).toBeVisible(); expect(shelfReads.length).toBeGreaterThan(0);expect(shelfReads.every(params=>!params.has('cursor'))).toBe(true); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 9c34b017..be335dd5 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -8,6 +8,21 @@ const saved=page=>history(page).getByRole('region',{name:'Saved prompt texts',ex const gallery=page=>page.getByRole('region',{name:'Shared gallery',exact:true}); const media=page=>gallery(page).locator('article[data-image-key]'); +const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); +/** The Library's Tags filter lives in "Refine this view" (#249 D14). */ +async function applyTags(page,tags) { + await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click(); + await refine(page).getByRole('textbox',{name:'Tags',exact:true}).fill(tags); + await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); +} +/** The first shelf page, then `pages` more, each waited for by its own response (the caption counts the filtered set). */ +async function loadShelfPages(page,more,pages) { + await expect(page.locator('.section-caption')).toContainText('203 positive prompts');await expect(more).toHaveAttribute('aria-disabled','false'); + for(let index=0;index{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');}); + await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false'); + } +} async function openHistory(page,text='Cat portrait') { await cards(page).filter({has:page.getByText(text,{exact:true})}).getByRole('link',{name:'Explore history',exact:true}).click(); await expect(history(page)).toBeVisible(); @@ -57,13 +72,13 @@ test('history displays the selected full negative beneath its positive and filte }); test('search, category and tag filters preserve their constraints and return standalone positives',async({page})=>{ - await page.goto(route);await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Dog landscape'); + await page.goto(route);await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Dog landscape'); await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape'); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(''); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Portraits'); - await page.getByRole('textbox',{name:'Tags',exact:true}).fill('studio');await expect(cards(page)).toHaveCount(1); - await page.getByRole('textbox',{name:'Tags',exact:true}).fill('outdoors');await expect(cards(page)).toHaveCount(0); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill(''); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits'); + await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1); + await applyTags(page,'outdoors');await expect(cards(page)).toHaveCount(0); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('');await expect(cards(page)).toHaveCount(1); }); test('library infinite loading keeps unique parents and keyboard-accessible history controls',async({page,request})=>{ @@ -97,11 +112,60 @@ test('failed library reads retry while late search responses cannot overwrite ne }); await page.goto(route);await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeVisible(); fail=false;await page.getByRole('button',{name:'Retry prompt library',exact:true}).click();await expect(cards(page)).toHaveCount(3); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog'); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});const oldRequest=page.waitForRequest(request=>new URL(request.url()).searchParams.get('text')==='Cat');await search.fill('Cat');await oldRequest;await search.fill('Dog'); await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); }); -test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{ +test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{ + await page.goto(route);await expect(cards(page)).toHaveCount(3); + // app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Filters. + const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1); + expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button']); + await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…'); + await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']); + await expect(toolbar.getByRole('button')).toHaveText(['Filters']); + await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0); + await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0); + await expect(page.locator('.library-shelf > search.toolbar:first-child')).toHaveCount(1); + // Tags apply from the dialog only; Clear clears them and the toolbar category, as the concept's does. + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits');await applyTags(page,'studio');await expect(cards(page)).toHaveCount(1); + await toolbar.getByRole('button',{name:'Filters',exact:true}).click(); + await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); + await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); + await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused(); + await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3); + const sorted=page.waitForRequest(item=>new URL(item.url()).searchParams.get('sort')==='name'); + await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption({label:'Name A–Z'});await sorted; +}); + +test('caption counts equal the filtered cards',async({page,request})=>{ + const totals=async query=>{const data=await(await request.get(`/prompt_manager/library?${query}`)).json();return [data.items.length,data.items.reduce((sum,item)=>sum+item.variant_count,0),data.variant_total];}; + await page.goto(route);await expect(cards(page)).toHaveCount(3); + const caption=page.locator('#workspace-main .section-caption'); + for(const [query,act] of [['',null],['category=Portraits',()=>page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Portraits')],['category=Portraits&tag=outdoors',()=>applyTags(page,'outdoors')]]) { + if(act)await act(); + const [count,variants,total]=await totals(query);expect(variants).toBe(total); + await expect(cards(page)).toHaveCount(count); + await expect(caption.locator('span')).toHaveText([`${count} positive prompt${count===1?'':'s'} · ${variants} negative variant${variants===1?'':'s'}`,'Images are grouped by positive prompt']); + } +}); + +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"]'); + 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); + const saved=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/images/favorite'&&item.request().method()==='PUT'); + await star.click();expect((await saved).ok()).toBe(true); + 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'); +}); + +test('a sort change atomically discards old row geometry and renders changed parent data',async({page,request})=>{ await page.goto(route);await expect(cards(page)).toHaveCount(3); const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id'); await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK(); @@ -111,7 +175,8 @@ test('refresh atomically discards old row geometry and renders changed parent da window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);}); }); try { - await page.getByRole('button',{name:'Refresh library',exact:true}).click(); + // D15: no Refresh; any filter change restarts the stream the same way. + await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest'); await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…'); } finally {await page.evaluate(()=>window.releaseLibraryFrames());} await expect(cards(page)).toHaveCount(3); @@ -122,20 +187,20 @@ test('refresh atomically discards old row geometry and renders changed parent da test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{ await page.setViewportSize({width:390,height:844});await page.goto(route); - await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals'); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1); + await page.getByRole('combobox',{name:'Filter by category',exact:true}).selectOption('Animals'); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('Cat portrait');await expect(cards(page)).toHaveCount(1); await openHistory(page);await selectVariant(page,0); await expect(saved(page).getByRole('region',{name:'Positive prompt',exact:true})).toContainText('Cat portrait'); expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1)).toBe(true); await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Cat portrait');await expect(cards(page)).toHaveCount(1); await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).click(); await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ await page.setViewportSize({width:1600,height:300});await page.goto(route); - await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive'); + await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive'); await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.'); await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight)); await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0); @@ -143,17 +208,17 @@ test('empty shelf visits restore saved scroll without inventing a parent anchor' await page.getByRole('navigation', { name: 'Tools and system', exact: true }).getByRole('link', { name: 'Overview', exact: true }).evaluate(node=>node.click()); await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).evaluate(node=>node.click()); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive'); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('No matching saved positive'); await expect(cards(page)).toHaveCount(0); await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape'); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.focus();await search.fill('Dog landscape'); await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused(); - await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1); + await page.evaluate(()=>scrollTo(0,0));await page.getByRole('combobox',{name:'Sort results',exact:true}).selectOption('oldest');await expect(cards(page)).toHaveCount(1); expect(await page.evaluate(()=>scrollY)).toBe(0); const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); await page.setViewportSize({width:390,height:844});await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); - await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape'); + await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('Dog landscape'); }); test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{ @@ -319,15 +384,14 @@ test('row measurement preserves anchor after width invalidation and append',asyn try { await page.setViewportSize({width:1600,height:1100});await page.goto(route); const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await loadShelfPages(page,more,3); await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25)); await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor); expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after); - await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after); + {const response=page.waitForResponse(item=>{const url=new URL(item.url());return url.pathname==='/prompt_manager/library'&&url.searchParams.has('cursor');});await more.evaluate(node=>node.click());await response;await expect(more).toHaveAttribute('aria-disabled','false');}await settleLibrary(page);await retainedParent(page,after); await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after); await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); @@ -349,8 +413,7 @@ test('queued row growth preserves newer native wheel movement and correction eve await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); await page.setViewportSize({width:1600,height:1100});await page.goto(route); const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await loadShelfPages(page,more,3); await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25)); await settleLibrary(page);await page.mouse.move(1100,550); const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); @@ -413,8 +476,7 @@ test('unchanged row observations settle without resize feedback',async({page,req breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId; } else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);} const more=page.getByRole('button',{name:'Load more prompts',exact:true}); - for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} - await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page); + await loadShelfPages(page,more,3);await settleLibrary(page); if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0); const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 3b4c3228..56e5dbc1 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -24,13 +24,17 @@ async function expectLibrary(page,{tags='',category='',count}) { await expect(page).toHaveURL(/#\/library$/); await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library'); await expect(main(page).getByRole('heading',{level:1})).toBeFocused(); - await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue(''); - await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags); - const select=page.getByRole('combobox',{name:'Category',exact:true}); + await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue(''); + const select=page.getByRole('combobox',{name:'Filter by category',exact:true}); await expect(select).toHaveValue(category); if(!category) await expect(select.locator('option:checked')).toHaveText('All categories'); - await expect(page.locator('.library-caption')).toHaveText(`${count} of ${count} positive prompts · Images are grouped by positive prompt`); + await expect(page.locator('#workspace-main .section-caption > span').first()).toHaveText(new RegExp(`^${count} positive prompts? · \\d+ negative variants?$`)); await expect(page.locator('article[data-prompt-id]')).toHaveCount(count); + // Browse sets exactly this Tags filter, which lives in "Refine this view" (#249 D14). + await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click(); + const refine=page.getByRole('dialog',{name:'Refine this view',exact:true}); + await expect(refine.getByRole('textbox',{name:'Tags',exact:true})).toHaveValue(tags); + await refine.getByRole('button',{name:'Close dialog',exact:true}).click();await expect(refine).toHaveCount(0); } let manifest; @@ -87,7 +91,7 @@ test('prompt counts equal Library Browse results',async({page})=>{ test('category Browse sets only the category constraint',async({page})=>{ await page.goto('/prompt_manager/workspace#/library'); - const search=page.getByRole('searchbox',{name:'Search prompts',exact:true}); + const search=page.getByRole('searchbox',{name:'Search workspace',exact:true}); await search.fill('observatory');await expect(page.locator('article[data-prompt-id]')).toHaveCount(1); await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Tags & categories(?:\s*[\d,]+)?$/}).click(); const row=categoryRow(page,'Landscapes');await expect(row.locator('p')).toHaveText('3 positive prompts'); diff --git a/tests/comfyui/library-sender.spec.mjs b/tests/comfyui/library-sender.spec.mjs index e147e24c..b3468884 100644 --- a/tests/comfyui/library-sender.spec.mjs +++ b/tests/comfyui/library-sender.spec.mjs @@ -22,7 +22,7 @@ test('native library sends the selected saved pair into a real ComfyUI destinati const workspace=await context.newPage();const errors=[];workspace.on('pageerror',error=>errors.push(error.message)); try { await workspace.goto('/prompt_manager/workspace#/library'); - await workspace.getByRole('searchbox',{name:'Search prompts',exact:true}).fill(positive); + 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('link',{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(); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 98724dc8..98cdc5ac 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -2,12 +2,21 @@ const test=require('node:test'); const assert=require('node:assert/strict'); const source=import('../../web/js/workspace/library-state.mjs'); const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,variant_count:2,image_count:4,previews:[]}); -const page=(items,cursor=null)=>({items,total:3,has_more:cursor!==null,next_cursor:cursor}); +const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor}); test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{ const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}}); assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false); const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose(); }); +test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{ + const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}]; + const s=createLibraryStream({api:{get:async()=>pending.shift()}});assert.equal(s.state.variantTotal,null); + await s.reset();assert.equal(s.state.variantTotal,6);await s.loadMore();assert.equal(s.state.variantTotal,7); + const reset=s.reset({text:'x'});assert.equal(s.state.variantTotal,null);await reset;assert.equal(s.state.variantTotal,1); + const restored=createLibraryStream({api:{},restore:{filters:{},state:s.state}});assert.equal(restored.state.variantTotal,1);restored.dispose(); + for(const bad of [undefined,-1,1.5,'2']){const broken=createLibraryStream({api:{get:async()=>({...page([item(1)]),variant_total:bad})}});await broken.reset();assert.ok(broken.state.error);assert.equal(broken.state.variantTotal,null);broken.dispose();} + s.dispose(); +}); test('reset aborts older requests and late success/failure cannot replace current parents',async()=>{ const {createLibraryStream}=await source;const requests=[];const s=createLibraryStream({api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({signal,resolve,reject}))}}); const first=s.reset({text:'first'});const second=s.reset({text:'second'});assert.equal(requests[0].signal.aborted,true);requests[1].resolve(page([item(2)]));await second;requests[0].resolve(page([item(1)]));await first;assert.deepEqual(s.state.items.map(row=>row.id),[2]); @@ -36,7 +45,7 @@ test('detail reads validate identities, abort stale selection and expose retryab test('restored shelves keep filters, loaded parents and continuation without a fresh first page',async()=>{ const {createLibraryStream}=await source;const old=createLibraryStream({api:{get:async()=>page([item(1)],'next')}});await old.reset({text:'retained',sort:'name'});old.dispose();const urls=[]; const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose(); - assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,cursor:null,hasMore:false}}})); + assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}})); }); test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{ const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 3c72a57a..ae0a9c63 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -381,6 +381,47 @@ class TestLibraryAPI(ImageAPITestCase): # D10/D20: the linked positive's title; "Pair title" names the variant. self.assertEqual(result["library"]["title"], "Positive title") + async def test_library_gallery_reports_favorites_with_one_lookup_per_page(self): + # The Library-embedded media cards carry the Images star (#249 PR E): + # favorites come from the store keyed by root and relative path. + pair = self.api.db.save_prompt_pair("Cat", "Noise") + positive = self.api.db.get_prompt_pair(pair)["positive_prompt_id"] + for index in range(5): + path = make_png(self.output_dir / "sub" / f"{index}.png") + image = self.api.db.link_image_to_prompt(positive, str(path)) + self.api.db.link_image_to_pair(pair, image, {"source": "fixture"}) + response = await self.client.put( + "/prompt_manager/images/favorite", + json={"root_index": 0, "path": "sub/3.png"}, + ) + self.assertEqual(response.status, 200) + lookups = [] + original = self.api.db.get_image_favorites + + def counted(root_key, paths): + lookups.append(list(paths)) + return original(root_key, paths) + + with patch.object(self.api.db, "get_image_favorites", side_effect=counted): + _, gallery = await self.get(f"/{positive}/images", pair_id=pair) + stars = {image["path"]: image["favorite"] for image in gallery["images"]} + self.assertEqual(stars, {f"sub/{index}.png": index == 3 for index in range(5)}) + # One batched read per page, never one per card. + self.assertEqual([len(paths) for paths in lookups], [5]) + # Shelf previews show no star and read no favorites. + lookups.clear() + with patch.object(self.api.db, "get_image_favorites", side_effect=counted): + _, listing = await self.get() + self.assertEqual(lookups, []) + self.assertNotIn("favorite", listing["items"][0]["previews"][0]) + response = await self.client.delete( + "/prompt_manager/images/favorite", + json={"root_index": 0, "path": "sub/3.png"}, + ) + self.assertEqual(response.status, 200) + _, gallery = await self.get(f"/{positive}/images", pair_id=pair) + self.assertFalse(any(image["favorite"] for image in gallery["images"])) + async def test_epoch_scoped_all_library_cursors(self): from database.execution_history_schema import rotate_history_epoch diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 478d4a20..bc54ac45 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -84,10 +84,16 @@ let lastVisit = null; // in flight and the page that receives each answer live here, beside the saved visit. const favoritePending = new Set(); let favoriteTarget = null; -/** Give a favorite state to the mounted Images page, or to the saved visit while none is mounted. */ +// Library-embedded galleries showing media cards (#249 PR E); keyed like the Images page, so each repaints its own copy. +const embeddedFavoriteTargets = new Set(); +/** + * Give a favorite state to the mounted Images page, or to the saved visit while none is mounted, and to every mounted + * Library-embedded gallery. + */ function deliverFavorite(key, favorite) { if (favoriteTarget) favoriteTarget(key, favorite); else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})}; + for (const target of embeddedFavoriteTargets) target(key, favorite); } export function mountImages(main, api, parentScope, {embedded = false, fetchPage} = {}) { @@ -278,8 +284,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const open = createButton('', 'image-open'); open.setAttribute('aria-label', `Open ${image.filename}`); open.append(mediaElement(image, true)); - // D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on - // Library-embedded media, which has no selection bar). + // D8: the selection checkbox (Images only: Library-embedded media has no selection bar), then D7: the star, + // wherever the listing reports favorites (Images and Library-embedded media). const overlays = el('div', undefined, 'image-overlays'); if (bar) { const label = el('label'), box = el('input'); @@ -322,10 +328,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const button = rendered.get(key)?.querySelector('.star-button'); if (button) paintStar(button, favorite, favoritePending.has(key)); } - // Only the Images page receives favorite answers; Library-embedded media has no star (#249 D7). + // The Images page and every Library-embedded gallery receive favorite answers (#249 D7, PR E). if (!embedded) { favoriteTarget = setFavorite; scope.cleanup(() => { if (favoriteTarget === setFavorite) favoriteTarget = null; }); + } else { + embeddedFavoriteTargets.add(setFavorite); + scope.cleanup(() => embeddedFavoriteTargets.delete(setFavorite)); } /** Optimistic toggle: the star changes at once and returns to its previous state if the store refuses. */ async function toggleFavorite(image) { diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index f8e21abf..52a771cd 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -14,15 +14,16 @@ function parameters(filters,limit) { if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); query.set('sort',sort);return query; } +// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). function validatePage(page,cursor) { - if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); + if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); } export function createLibraryStream({api,onChange=()=>{},restore}) { - let state=Object.freeze({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null}); + let state=Object.freeze({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null}); let query=parameters({},40),request,disposed=false,seen=new Set(); if(restore) { query=parameters(restore.filters,40);const saved=restore.state; - validatePage({items:saved.items,total:saved.total,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); + validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent'); state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id)); } @@ -37,11 +38,11 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { validatePage(page,cursor);const incoming=[];const added=new Set(); for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));} for(const value of added) seen.add(value); - request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; + request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; } catch(error) {if(!disposed && current===request) {request=null;emit({loading:false,error});}return 0;} } return {get state(){return state;},loadMore,async reset(filters={}) { - if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); + if(disposed) return 0;const next=parameters(filters,40);request?.abort();request=null;query=next;seen=new Set();emit({items:Object.freeze([]),total:0,variantTotal:null,cursor:null,hasMore:true,loading:false,error:null});return loadMore(); },dispose(){disposed=true;request?.abort();request=null;}}; } /** diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index f169bb0c..bdb76329 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -1,12 +1,5 @@ main.library-main { --library-positive: #73dda9; } .library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; } -.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; } -.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; } -.library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; } -.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } -.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; } -.ca-tools .library-main .library-toolbar > button { inline-size: auto; } -.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; } .library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } .library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } .library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index fc623095..9bc40e5c 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -1,4 +1,4 @@ -import {createScope,createButton,createField,createSelect} from '../shared/toolkit/core.js'; +import {createScope,createButton} from '../shared/toolkit/core.js'; import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; @@ -11,6 +11,8 @@ import {createWorkspaceIcon} from './icons.mjs'; 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'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. @@ -23,6 +25,9 @@ const selectedPairs=new Map(); // Parent metadata a reuse did not apply, kept for an explicit Edit metadata action. const metadataDrafts=new Map(); const archivedVisible=new Map(); +// The concept's three sorts first, then the Library's appended Oldest first (D14). +const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]); +const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.'; const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.'; function el(tag,text,className) { const node=document.createElement(tag);if(text!==undefined) node.textContent=text;if(className) node.className=className;return node; @@ -144,15 +149,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const restorable=visit?.state?visit:null; const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); - const toolbar=el('search',undefined,'library-toolbar'); - const search=el('input');search.type='search';search.placeholder='Search prompts and saved negatives…';search.value=filters.text;search.maxLength=500; - const category=createSelect([['','All categories']], '');category.value=filters.category; - const tags=el('input');tags.type='text';tags.placeholder='Comma-separated tags';tags.value=filters.tags.join(', ');tags.maxLength=2000; - const sort=createSelect([['newest','Recently added'],['oldest','Oldest first'],['name','Prompt A–Z'],['rating','Highest rated']], '');sort.value=filters.sort; - const refresh=createButton('Refresh library'); - toolbar.append(createField('Search prompts',search,'library-search'),createField('Category',category,'library-category'),createField('Tags',tags,'library-tags'),createField('Sort',sort,'library-sort'),refresh); + // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). + // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. + const toolbarScope=createScope(); + const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS, + value:{q:filters.text,category:filters.category,sort:filters.sort},onChange:applyToolbar,onFilters:openFilters}); const optionsStatus=el('p','','library-options-status');optionsStatus.hidden=true;optionsStatus.setAttribute('role','alert'); - const caption=el('p','','library-caption');caption.setAttribute('role','status'); + // The concept caption (app.js renderLibrary a586): the filtered set's parents and their negative variants. + const caption=el('div',undefined,'section-caption');const captionCount=el('span');caption.append(captionCount,el('span','Images are grouped by positive prompt'));caption.hidden=true; const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts'); const message=el('p','','library-status');message.setAttribute('role','status'); const continuation=el('div',undefined,'library-continuation'); @@ -184,7 +188,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } scope.listen(exportButton,'click',exportLibrary); headingDescription(main,section,'One positive. Reusable negatives. Every experiment stays connected.'); - continuation.append(message,retry,more);section.append(toolbar,optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); + continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation,legacy);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; @@ -337,7 +341,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } else if(retainedAnchor) {pendingAnchor=retainedAnchor;retainedAnchor=null;} } state=next; - caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; + caption.hidden=next.variantTotal===null;if(next.variantTotal!==null) captionCount.textContent=`${countOf(next.total,'positive prompt')} · ${countOf(next.variantTotal,'negative variant')}`; const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; message.textContent=(next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); @@ -354,14 +358,29 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { optionsRequest?.abort();const request=optionsRequest=new AbortController(); try { const data=await api.get('/prompt_manager/library/options',{signal:request.signal});if(scope.signal.aborted || optionsRequest!==request) return; - const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true; - } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}} + toolbar.setCategories([...data.categories,filters.category]);toolbar.setValue({category:filters.category});optionsStatus.hidden=true; + } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent=CATEGORIES_FAILED;optionsStatus.hidden=false;}} } const reset=()=>{restoreScrollY=null;return stream.reset(filters);}; - scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200)); - scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); - scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); - scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); + /** A toolbar report; a debounced search that lands after a select already applied the same values reloads nothing. */ + function applyToolbar(value) { + const next={text:value.q.trim(),category:value.category,sort:value.sort}; + if(Object.entries(next).every(([key,item])=>filters[key]===item)) return; + Object.assign(filters,next);reset(); + } + /** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */ + function openFilters(trigger) { + openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement, + onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + } + function applyRefinement({tags,category=filters.category}) { + const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))]; + if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; + filters.category=category;filters.tags=next;reset(); + } + scope.listen(more,'click',()=>stream.loadMore()); + // Retry also re-reads a category list that failed (D15: Refresh is gone). + scope.listen(retry,'click',()=>{if(!optionsStatus.hidden)loadCategories();stream.loadMore();}); scope.listen(window,'scroll',()=>{reconcileScroll();schedule();}); // Mobile viewport resizing can adjust scroll before delivering its resize event. scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();}); @@ -369,7 +388,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const savedFilters={...filters,tags:[...filters.tags]},savedAnchor=pendingAnchor || anchor || captureAnchor(); // A write while mounted makes this stream stale: keep only what still means something. shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null; - stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset(); }