diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index dd15012..ee5ac6d 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -9,6 +9,8 @@ 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');}; 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); @@ -91,7 +93,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 expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + await open(page,f.detail_id);await showAllImages(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 { @@ -395,7 +397,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 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 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 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(); @@ -520,7 +522,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 expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const f=page.fixture;await open(page,f.detail_id);await showAllImages(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'}}})); @@ -531,7 +533,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 expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + 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 positive=saved(page).getByRole('region',{name:'Positive prompt',exact:true});const original=await exact(positive.locator('pre')); 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++;}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 8193f09..7d70ad5 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -179,6 +179,22 @@ test('a failed archive keeps focus on the Archive confirmation inside the dialog expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); }); +test('an unconfirmed archive moves focus to Retry instead of dropping it out of the dialog',async({page,request})=>{ + const f=page.fixture;await mountEditor(page);let dropped=false; + await page.route(`**/prompt_manager/pairs/${f.pair_id}`,async route=>{ + if(route.request().method()==='PATCH'&&!dropped){dropped=true;await route.fetch();await route.abort('failed');return;} + await route.continue(); + }); + const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); + await button(dialog,'Archive…').click();await expect(button(dialog,'Archive')).toBeEnabled(); + await button(dialog,'Archive').click(); + await expect(note(dialog)).toHaveText('Archive could not be confirmed. Retry sends the same archive once.'); + await expect(button(dialog,'Archive')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); + expect(page.writes).toHaveLength(1); + await page.keyboard.press('Enter');await expect(dialog).toHaveCount(0); + expect(page.writes).toHaveLength(2);expect((await pairRead(request,f.pair_id)).archived).toBe(true); +}); + test('an archive conflict offers an explicit Reload that re-reads and never replays the archive',async({page,request})=>{ const f=page.fixture;await mountEditor(page); let conflicted=false; @@ -262,7 +278,7 @@ test('recovery states render inline and never replay a write',async({page,reques dialog=await openEditor(page,{mode:'new'});await ready(dialog); await field(dialog,'Title').fill('Lost response');await field(dialog,'Positive prompt').fill('Lost response prompt');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry sends the same save once.'); - await expect(button(dialog,'Save')).toBeDisabled(); + await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); await page.unroute('**/prompt_manager/library'); await page.route('**/prompt_manager/library/*?variant_limit=40',route=>route.fulfill({status:500,contentType:'application/json',body:'{"success":false,"error":"boom"}'}),{times:1}); await button(dialog,'Retry').click(); @@ -343,8 +359,11 @@ test('an unconfirmed Edit save says Retry reloads for review and resends nothing const dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog); await field(dialog,'Notes').fill('Unconfirmed notes');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.'); - await button(dialog,'Retry').click(); + // Save is disabled now; focus moves to the one recovery action instead of falling out of the modal. + await expect(button(dialog,'Save')).toBeDisabled();await expect(button(dialog,'Retry')).toBeFocused(); + await page.keyboard.press('Enter'); await expect(note(dialog)).toHaveText('Loaded the latest saved values. Review your entries, then save.'); + expect(await dialog.evaluate(node=>node.contains(document.activeElement))).toBe(true); await expect(field(dialog,'Notes')).toHaveValue('Unconfirmed notes');expect(page.writes).toHaveLength(1); }); @@ -426,12 +445,19 @@ async function openHistory(page,id) { async function selectPair(page,name) { const dot=variantsPanel(page).getByRole('button',{name,exact:true});await dot.click();await expect(dot).toHaveAttribute('aria-pressed','true'); } +/** In-app navigation away and back, as the concept's hash router does (app.js navigate: state.variant = 0). */ +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}); 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(); const actions=headingActions(page).locator('button'); expect(await visibleNames(actions)).toEqual(['Export','+ New prompt']); + // One linked image is counted in the singular. + const f=page.fixture;await expect(page.locator(`article[data-prompt-id="${f.positive_id}"]`).getByText('1 linked image',{exact:true})).toHaveCount(1); await expect(actions.nth(0)).toHaveAccessibleName('Export full Library JSON');await expect(actions.nth(0)).not.toHaveClass(/ca-primary/); await expect(actions.nth(1)).toHaveClass(/\bca-primary\b/); for(const index of [0,1])expect((await actions.nth(index).boundingBox()).height).toBeGreaterThanOrEqual(32); @@ -443,6 +469,10 @@ test('library heading offers Export then + New prompt, and New opens the saved p await expect(page.locator('#workspace-main h1')).toHaveText('Harbor at dawn');await expect(page.locator('#workspace-main h1')).toBeFocused(); await expect(toast(page)).toHaveText('Saved.'); await expect(pressedVariant(page)).toHaveCount(1);await expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true})).toContainText('fog, noise'); + // Exactly one saved negative is counted in the singular, in history and on the shelf card. + await expect(variantsPanel(page).getByText('1 of 1 saved negative variant',{exact:true})).toHaveCount(1); + await page.getByRole('button',{name:'← Prompt library',exact:true}).click(); + await expect(page.locator(`article[data-prompt-id="${created.id}"]`).getByText('1 saved negative variant',{exact:true})).toHaveCount(1); expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library']); }); @@ -468,7 +498,8 @@ test('history heading follows the concept and sends the selected saved pair',asy 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)']); await expect(variantsPanel(page).getByRole('button',{name:'Show archived (1)',exact:true})).toHaveAttribute('aria-pressed','false'); - await expect(send).toBeDisabled(); + // 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(); await selectPair(page,'Select negative variant 2');await expect(send).toBeEnabled(); await expect(savedTexts(page).getByRole('button',{name:'Send prompt pair',exact:true})).toHaveCount(0); await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true});await expect(sender).toBeVisible(); @@ -477,6 +508,18 @@ 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})=>{ + 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'); + 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 leaveAndReturn(page,f.positive_id); + await expect(pressedVariant(page)).toHaveAccessibleName('Select negative variant 1');await expect(all).toHaveAttribute('aria-pressed','false'); + await expect(headingActions(page).locator('button.send')).toBeEnabled();expect(page.writes).toHaveLength(0); +}); + test('add negative re-renders history with the saved or reused pair selected',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const add=variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}); @@ -488,8 +531,10 @@ 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('pre')).toHaveText(f.selected_negative); - await expect(laneLabel(page,'· 1 linked images')).toHaveCount(1); + await expect(laneLabel(page,/· 1 linked image$/)).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'); expect(page.writes.map(write=>write.url)).toEqual([`/prompt_manager/library/${f.positive_id}/variants`,`/prompt_manager/library/${f.positive_id}/variants`]); }); @@ -550,6 +595,7 @@ test('restored storage after a confirmed save or before an archive offers an exp await field(dialog,'Notes').fill('Lost to a restore');await button(dialog,'Save').click(); await expect(note(dialog)).toHaveText('Saved; Library refresh failed because prompt storage was restored. Reload the Library to see current data.'); expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); + for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0); await expect(variantsPanel(page)).toBeVisible();await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title); await page.unroute(`**/prompt_manager/library/${f.positive_id}`);expect(page.writes).toHaveLength(1); @@ -563,6 +609,7 @@ test('restored storage after a confirmed save or before an archive offers an exp expect((await pairRead(request,f.pair_id)).archived).toBe(false); }); +const DISCARDS='Leaving this dialog discards your entries.'; test('restored storage before an Edit or Add negative save offers only the Library reload exit',async({page,request})=>{ const f=page.fixture;const snapshot=await(await request.get('/__test__/library-editor/snapshot')).body(); const restore=()=>request.post('/prompt_manager/restore',{multipart:{database_file:{name:'editor.db',mimeType:'application/octet-stream',buffer:snapshot}}}); @@ -570,9 +617,12 @@ test('restored storage before an Edit or Add negative save offers only the Libra await page.getByRole('button',{name:'Edit metadata',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); await expect(await restore()).toBeOK(); await field(dialog,'Notes').fill('Typed before the restore');await button(dialog,'Save').click(); - await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); + await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`); expect(await actionNames(dialog)).toEqual(['Reload Library','Create text revision','Archive…','Cancel','Save']); await expect(button(dialog,'Reload Library state')).toHaveCount(0);await expect(button(dialog,'Save')).toBeDisabled(); + // Revise and archive would re-read the old numeric IDs, which may now name another prompt. + for(const name of ['Create text revision','Archive…'])await expect(button(dialog,name)).toBeDisabled(); + await expect(button(dialog,'Reload Library')).toBeFocused(); expect(page.writes).toHaveLength(1);// the refused save await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); expect(page.writes).toHaveLength(1);expect((await detail(request,f.positive_id)).positive.notes).toBe(f.metadata.notes); @@ -580,7 +630,7 @@ test('restored storage before an Edit or Add negative save offers only the Libra await variantsPanel(page).getByRole('button',{name:'+ Add negative',exact:true}).click();dialog=editorDialog(page);await ready(dialog); await expect(await restore()).toBeOK(); await field(dialog,'Negative prompt').fill('typed before the restore');await button(dialog,'Save').click(); - await expect(note(dialog)).toHaveText('Prompt storage was restored since this dialog opened. Nothing was saved.'); + await expect(note(dialog)).toHaveText(`Prompt storage was restored since this dialog opened. Nothing was saved. ${DISCARDS}`); expect(await actionNames(dialog)).toEqual(['Reload Library','Cancel','Save']); await expect(button(dialog,'Reload Library state')).toHaveCount(0);expect(page.writes).toHaveLength(2); await button(dialog,'Reload Library').click();await expect(dialog).toHaveCount(0);await expect(variantsPanel(page)).toBeVisible(); @@ -623,12 +673,13 @@ test('archive hides a pair, Show archived finds it and Restore keeps it selected 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 images · Archived'});await expect(lane).toHaveCount(1); + 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 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 expect(savedTexts(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(f.selected_negative); await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).locator('article[data-image-key]')).toHaveCount(1); + await expect(page.getByRole('region',{name:'Shared gallery',exact:true}).getByText('1 loaded · 1 linked image',{exact:true})).toBeVisible(); expect(galleryReads.at(-1).get('archive')).toBe('archived');expect(galleryReads.at(-1).get('pair_id')).toBe(String(f.pair_id)); await expect(headingActions(page).getByRole('button',{name:/^Send prompt pair to ComfyUI/})).toBeEnabled(); const writes=page.writes.length; @@ -713,6 +764,37 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra expect(page.writes).toHaveLength(1); }); +test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{ + const f=page.fixture;await page.goto(`${workspace}#/library`); + await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative'); + 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('Saved as a new negative variant of an existing prompt.'); + await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog); + await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click(); + expect(page.writes).toHaveLength(1); +}); + +test('reuse of an uncategorized prompt with the category left blank is not unapplied metadata, a different category is',async({page,request})=>{ + const f=page.fixture;const current=await detail(request,f.positive_id,'active'); + await expect(await request.patch(`/prompt_manager/library/${f.positive_id}`,{data:{expected_epoch:current.library_epoch,if_match:current.positive.edit_token,metadata:{category:''}}})).toBeOK(); + expect((await detail(request,f.positive_id)).positive.category).toBeNull(); + const reuse=async(category,negative)=>{ + await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog); + await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative); + await field(dialog,'Category').fill(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 reuse('','blank category negative'); + await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.'); + await reuse('Landscapes','different category negative'); + await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'); + expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']); +}); + test('turning Show archived on again after a hidden archive shows the newly archived pair',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); const toggle=()=>variantsPanel(page).getByRole('button',{name:/^Show archived/}); @@ -723,7 +805,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 images · Archived')).toHaveCount(1);await uniqueDotNames(page); + await expect(laneLabel(page,'· 1 linked image · 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 800073c..6312493 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -37,7 +37,11 @@ test('library groups reusable negatives under one positive without exposing nega test('history displays the selected full negative beneath its positive and filters one shared physical gallery',async({page,request})=>{ const rows=await(await request.get('/prompt_manager/library')).json();const cat=rows.items.find(item=>item.text==='Cat portrait'); const data=await(await request.get(`/prompt_manager/library/${cat.id}`)).json(); - await page.goto(route);await openHistory(page);await expect(media(page)).toHaveCount(3); + // 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(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'); 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}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 4975a81..8243a42 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -612,6 +612,8 @@ 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. + await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); await expect(page.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); diff --git a/tests/js/workspace-library-editor-state.test.js b/tests/js/workspace-library-editor-state.test.js index 5d60501..ff1826d 100644 --- a/tests/js/workspace-library-editor-state.test.js +++ b/tests/js/workspace-library-editor-state.test.js @@ -788,3 +788,22 @@ test('a refresh failed by restored storage cannot recover through retryReconcili assert.equal(server.writes().length, 1); controller.dispose(); }); + +test('a blank category reused against an uncategorized owner is not unapplied metadata; a different one is', async () => { + const { createLibraryEditorController } = await source; + for (const [stored, entered, expected] of [[null, '', false], ['', ' ', false], ['Uncategorized', '', false], [null, 'Uncategorized', false], + [null, 'Landscapes', true], ['Studio', '', true]]) { + const owner = positive({ id: 9, text: 'a portrait', title: 'Portrait', category: stored, tags: [], rating: null, notes: '' }); + const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false, positive: owner }); + const server = harness(created(() => response(200, reused))); + const controller = createLibraryEditorController({ api: server.api, mode: 'new' }); + await controller.initialize(); + controller.setField('title', 'Portrait'); + controller.setField('positive_text', 'a portrait'); + controller.setField('category', entered); + await controller.submit(); + assert.equal(controller.state.phase, 'complete'); + assert.equal(controller.state.outcome.metadataNotApplied, expected, `stored ${stored} entered ${JSON.stringify(entered)}`); + controller.dispose(); + } +}); diff --git a/tests/js/workspace-plural.test.js b/tests/js/workspace-plural.test.js new file mode 100644 index 0000000..b0f470d --- /dev/null +++ b/tests/js/workspace-plural.test.js @@ -0,0 +1,15 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/plural.mjs'); + +test('countOf uses the singular noun only for exactly one', async () => { + const { countOf, noun } = await source; + assert.equal(countOf(1, 'linked image'), '1 linked image'); + assert.equal(countOf(0, 'linked image'), '0 linked images'); + assert.equal(countOf(2, 'saved negative variant'), '2 saved negative variants'); + assert.equal(countOf(1, 'distinct recorded run'), '1 distinct recorded run'); + assert.equal(countOf(149, 'distinct recorded run'), '149 distinct recorded runs'); + assert.equal(noun(1, 'image'), 'image'); + assert.equal(noun(202, 'image'), 'images'); + assert.equal(noun(2, 'child', 'children'), 'children'); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 0719181..444263d 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -170,14 +170,14 @@ "workspace/history-state.mjs": { "lines": 97, "branches": 91, - "functions": 96, + "functions": 98, "statements": 97 }, "workspace/history.mjs": { - "lines": 96, + "lines": 97, "branches": 87, "functions": 98, - "statements": 96 + "statements": 97 }, "workspace/icons.mjs": { "lines": 98, @@ -217,13 +217,13 @@ }, "workspace/library-editor-state.mjs": { "lines": 96, - "branches": 91, + "branches": 92, "functions": 98, "statements": 96 }, "workspace/library-editor.mjs": { "lines": 98, - "branches": 88, + "branches": 93, "functions": 92, "statements": 98 }, @@ -235,14 +235,14 @@ }, "workspace/library-state.mjs": { "lines": 98, - "branches": 94, + "branches": 95, "functions": 98, "statements": 98 }, "workspace/library.mjs": { "lines": 95, "branches": 84, - "functions": 94, + "functions": 96, "statements": 95 }, "workspace/media-viewer.mjs": { @@ -251,6 +251,12 @@ "functions": 98, "statements": 98 }, + "workspace/plural.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "workspace/prompt-blocks.mjs": { "lines": 89, "branches": 89, @@ -272,7 +278,7 @@ "workspace/recorded-run.mjs": { "lines": 95, "branches": 83, - "functions": 71, + "functions": 77, "statements": 95 }, "workspace/state.mjs": { diff --git a/web/js/workspace/history.mjs b/web/js/workspace/history.mjs index 4aadedb..7015c9f 100644 --- a/web/js/workspace/history.mjs +++ b/web/js/workspace/history.mjs @@ -1,6 +1,7 @@ import {createScope, createButton} from '../shared/toolkit/core.js'; import {createHistoryController, visibleHistoryColumns} from './history-state.mjs'; import {mountRecordedRun} from './recorded-run.mjs'; +import {countOf} from './plural.mjs'; function el(tag, text, className) { const node = document.createElement(tag); @@ -160,7 +161,7 @@ export function mountRecordedHistory(container, {api, positiveId, parentScope, s if (index !== undefined) timeline.scrollLeft = Math.max(0, 38 + index * 64 - anchor.x); } } - count.textContent = window.group ? `${window.recorded_run_count} distinct recorded runs as of this read · ${window.items.length} loaded positions` : ''; + count.textContent = window.group ? `${countOf(window.recorded_run_count, 'distinct recorded run')} as of this read · ${window.items.length} loaded positions` : ''; const group = window.group; scopeText.textContent = group?.scope_status === 'uncapturable_text' ? 'This saved positive cannot be matched within frozen-text capture limits.' : 'Runs are grouped by exact frozen positive raw text, independently of saved pairs. Effective text, prepend/append composition and evidence can differ. Reported output locations do not establish context membership or historical file identity.'; status.setAttribute('role', window.error || next.run.error ? 'alert' : 'status'); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index b4799fd..29e4589 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -6,6 +6,7 @@ import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { createMediaViewer } from './media-viewer.mjs'; +import { noun } from './plural.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -322,7 +323,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage for (let index = state?.items.length || 0; index < next.items.length; index++) itemIndexes.set(imageKey(next.items[index]), index); } state = next; - count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} linked images` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} images`; + count.textContent = embedded ? `${next.loaded.toLocaleString()} loaded · ${next.total.toLocaleString()} ${noun(next.total, 'linked image')}` : `${next.loaded.toLocaleString()} of ${next.total.toLocaleString()} ${noun(next.total, 'image')}`; if (changed) updateLayout(); message.textContent = recovering ? 'Images paused until sources and folders are available.' : next.error ? 'Images could not be loaded. Retry or refresh the collection.' : next.loading ? 'Loading images…' : ''; if (next.error) message.setAttribute('role', 'alert'); else message.setAttribute('role', 'status'); diff --git a/web/js/workspace/library-editor-state.mjs b/web/js/workspace/library-editor-state.mjs index 9c09469..bc8fd1e 100644 --- a/web/js/workspace/library-editor-state.mjs +++ b/web/js/workspace/library-editor-state.mjs @@ -144,8 +144,10 @@ function createMetadata(draft) { return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags), rating: draft.rating, notes: draft.notes}; } +// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it. +const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value)); function sameMetadata(sent, owner) { - return sent.title === stored(owner.title) && sent.category === stored(owner.category) && sameTags(sent.tags, owner.tags) && + return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) && sent.rating === owner.rating && sent.notes === (owner.notes ?? ''); } function draftProblem(mode, draft) { diff --git a/web/js/workspace/library-editor.mjs b/web/js/workspace/library-editor.mjs index d964d57..67ec411 100644 --- a/web/js/workspace/library-editor.mjs +++ b/web/js/workspace/library-editor.mjs @@ -21,6 +21,9 @@ const REVIEW = 'Loaded the latest saved values. Review your entries, then save.' const REVIEW_ARCHIVE = 'Loaded the latest saved values. Review them, then archive.'; const TOAST_MS = 6500; const REFRESH = ['Reload Library', 'refresh']; +// The concept has no discard confirmation (its Cancel and × simply close), so the state says it inline: +// every exit (Reload Library, Cancel, × or Escape) drops the draft. +const DISCARDS = 'Leaving this dialog discards your entries.'; let toastTimer; /** Show one outcome in the shared workspace toast (role="status"); it clears itself like the concept's. */ @@ -42,7 +45,8 @@ function el(tag, text, className) { /** * Inline state copy and its one recovery action; `archive` selects the pair-archive * wording. After a restore only New prompt may re-read: every other mode names an - * owner by its old numeric ID, so it offers only the Library refresh exit. + * owner by its old numeric ID, so it offers only the Library refresh exit, which + * discards the draft. */ function statusOf(state, archive, mode = null) { const nothing = archive ? 'Nothing was archived.' : 'Nothing was saved.'; @@ -54,7 +58,8 @@ function statusOf(state, archive, mode = null) { case 'write-error': return {text: `${archive ? 'Not archived' : 'Not saved'}: ${message} Your entries are kept.`}; case 'conflict': return {text: archive ? 'This negative variant changed since it was loaded. Reload it and review it before archiving.' : 'This prompt changed since it was loaded. Reload it and review your entries before saving.', action: ['Reload', 'reload']}; - case 'storage-changed': return {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: mode === 'new' ? ['Reload Library state', 'reload'] : REFRESH}; + case 'storage-changed': return mode === 'new' ? {text: `Prompt storage was restored since this dialog opened. ${nothing}`, action: ['Reload Library state', 'reload']} + : {text: `Prompt storage was restored since this dialog opened. ${nothing}${archive ? '' : ` ${DISCARDS}`}`, action: REFRESH}; case 'unknown-outcome': return {text: archive ? 'Archive could not be confirmed. Retry sends the same archive once.' : mode === 'metadata' ? 'Save could not be confirmed. Your entries are kept; Retry reloads the latest saved values for review and sends nothing.' : 'Save could not be confirmed. Your entries are kept; Retry sends the same save once.', action: ['Retry', 'retryUnconfirmed']}; @@ -86,12 +91,25 @@ function setChildren(parent, nodes) { if (current.length !== nodes.length || current.some((node, index) => node !== nodes[index])) parent.replaceChildren(...nodes); } -/** A button that held focus when saving began gets it back after a failed or conflicting write (disabling drops focus to the body). */ -function restoreFocus(button, state) { - if (!button || state.phase === 'saving') return button; - if (state.phase !== 'complete' && button.isConnected && !button.disabled && (!document.activeElement || document.activeElement === document.body)) button.focus(); +/** Saving or re-reading disables or removes the focused action; focus would drop to the body. */ +const SETTLING = new Set(['saving', 'initializing']); +const restoredStorage = state => state.phase === 'storage-changed' || (state.phase === 'saved-refresh-error' && state.error?.code === 'storage-changed'); +/** + * The action that held focus when a write or re-read began gets it back once that + * settles; when it is gone or disabled (Save after an unconfirmed write), focus moves + * to the first usable fallback, the recovery action first, and never to the body. + */ +function restoreFocus(button, state, fallbacks) { + if (!button || SETTLING.has(state.phase)) return button; + if (state.phase !== 'complete' && (!document.activeElement || document.activeElement === document.body)) { + [button, ...fallbacks].find(node => node?.isConnected && !node.disabled)?.focus(); + } return null; } +/** Remember a focused dialog action while the phase settles. */ +function settlingFocus(actions, state, current) { + return SETTLING.has(state.phase) && actions.contains(document.activeElement) ? document.activeElement : current; +} function field(label, control, id) { const wrapper = el('div', undefined, 'field'); @@ -246,9 +264,11 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o categories.replaceChildren(...datalist('', state.categories).children); } const saving = state.phase === 'saving'; - if (saving && document.activeElement === save) refocus = save; + refocus = settlingFocus(actions, state, refocus); save.disabled = !SUBMITTABLE.has(state.phase); - cancel.disabled = close.disabled = revise.disabled = saving; + cancel.disabled = close.disabled = saving; + // After a restore the numeric IDs may name another prompt; only the Library reload exit stays. + revise.disabled = saving || restoredStorage(state); const pairProblem = readOnly && !state.pair && pairId === null ? NO_PAIR : state.pair?.archived ? ALREADY_ARCHIVED : null; archive.disabled = !SUBMITTABLE.has(state.phase) || !state.pair || state.pair.archived; const status = statusOf(state, false, mode); @@ -257,7 +277,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o else archive.removeAttribute('aria-describedby'); const retry = recoveryButton(recovery, status?.action, views.signal); setChildren(actions, [...(retry ? [retry] : []), ...(readOnly ? [revise, archive] : []), cancel, save]); - refocus = restoreFocus(refocus, state); + refocus = restoreFocus(refocus, state, [retry, save]); if (state.phase === 'complete') finish(); } @@ -322,11 +342,11 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o const retry = recoveryButton(recovery, status?.action, views.signal) ?? (readFailed ? reread : null); links.hidden = !counted; const saving = state.phase === 'saving'; - if (saving && document.activeElement === confirm) refocus = confirm; + refocus = settlingFocus(actions, state, refocus); confirm.disabled = !counted || !SUBMITTABLE.has(state.phase); cancel.disabled = close.disabled = saving; setChildren(actions, [...(retry ? [retry] : []), cancel, confirm]); - refocus = restoreFocus(refocus, state); + refocus = restoreFocus(refocus, state, [retry, confirm]); if (state.phase === 'complete') finish(); } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 57bc4cf..1b6c497 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -9,11 +9,13 @@ import {createLibraryEditorController} from './library-editor-state.mjs'; import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; import {createWorkspaceIcon} from './icons.mjs'; import {mountRecordedHistory} from './history.mjs'; +import {countOf} from './plural.mjs'; let shelfVisit=null; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null; -const variantSelections=new Map(); +// The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986). +const savedSelections=new Map(); const historyVisits=new Map(); // Exact-ID pair reads for selections outside the bounded variant page; never merged into it. const selectedPairs=new Map(); @@ -39,32 +41,38 @@ export function invalidateLibraryAfterWrite(commit) { writeGeneration++; if(shelfVisit) shelfVisit={filters:shelfVisit.filters,anchor:shelfVisit.layout?.anchor ?? shelfVisit.anchor ?? null}; } -/** Remember the selection a reconciled save names, so the destination history opens on it. */ +/** Remember the selection a reconciled save names, so the destination history opens on it once. */ export function rememberLibraryResult(result) { const positive=result?.detail?.positive, kind=result?.commit?.kind; if(!positive || !Number.isSafeInteger(positive.id) || !Array.isArray(result.detail.variants) || typeof kind!=='string') throw new TypeError('Invalid reconciled Library result'); if(kind==='create') savedParentId=positive.id; if(!result.pair || kind==='archive-pair') return; - variantSelections.set(positive.id,result.pair.id); + savedSelections.set(positive.id,result.pair.id); if(result.detail.variants.some(variant=>variant.id===result.pair.id)) selectedPairs.delete(positive.id); else selectedPairs.set(positive.id,result.pair); if(result.pair.archived) archivedVisible.set(positive.id,true); } /** * Toast copy from the receipt: the concept's save toast for New, Revision and Edit and its - * Add-negative line (app.js 989, 1028); a reuse discloses what was not saved. + * Add-negative line (app.js 989, 1028); a reuse discloses what was not saved, and only + * when the controller found the entered parent metadata differs from the stored one. */ -function outcomeMessage(result) { +function outcomeMessage(result,outcome) { const {kind,receipt}=result.commit; if(kind==='metadata') return 'Saved.'; if(kind==='archive-pair') return ARCHIVED_MESSAGE; if(kind!=='create' && kind!=='add-negative') return null; if(receipt.positive_created) return 'Saved.'; if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.' - :'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'; + :outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.' + :'Saved as a new negative variant of an existing prompt.'; if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.'; return 'This prompt pair already exists. Showing the saved version.'; } +/** Take the saved selection for one positive; it never outlives the visit that consumes it. */ +function takeSavedSelection(positiveId) { + const pairId=savedSelections.get(positiveId);savedSelections.delete(positiveId);return pairId ?? null; +} function rememberUnappliedMetadata(result,{outcome,draft}={}) { if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return; const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes}); @@ -73,7 +81,7 @@ function rememberUnappliedMetadata(result,{outcome,draft}={}) { function createNewPromptEditor(api,scope,main) { return createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:(result,context)=>{ rememberLibraryResult(result);rememberUnappliedMetadata(result,context); - const message=outcomeMessage(result);if(message) showWorkspaceToast(message); + const message=outcomeMessage(result,context?.outcome);if(message) showWorkspaceToast(message); const hash=`#/library/${result.detail.positive.id}`; if(window.location.hash===hash) focusHeading(main);else {focusHeadingOnMount=true;window.location.hash=hash;} }}); @@ -172,7 +180,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { heading.append(h2,el('span',item.category || 'Uncategorized','library-chip')); const prompts=el('div',undefined,'library-card-prompts'); const blocks=mountPromptBlocks(prompts,{prompts:{positive:{status:'resolved',text:item.text,source:'library'}},sender,parentScope:scope,roles:['positive']}); - const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`)); + const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?countOf(item.variant_count,'saved negative variant'):'No saved negatives','library-chip library-chip-negative'),el('span',countOf(item.image_count,'linked image'))); const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip')); content.append(heading,prompts,stats,vocabulary); const footer=el('footer',undefined,'library-card-footer'); @@ -342,6 +350,10 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send); let blocks,galleryScope,detail,contentScope,updateBranches,updateParent,current=null; scope.cleanup(()=>{blocks?.dispose();galleryScope?.dispose();contentScope?.dispose();}); + // Every visit opens on the first negative, as the concept's navigate() does (app.js 1149: state.variant = 0); + // only the pair a save just named is selected instead. A verified pair outside the page belongs to that save. + let pendingSelection=takeSavedSelection(positiveId); + if(selectedPairs.get(positiveId)?.id!==pendingSelection) selectedPairs.delete(positiveId); const loader=createLibraryDetail({api,onChange:render});scope.cleanup(()=>loader.dispose()); const editor=createLibraryEditor({api,parentScope:scope,onCommitted:invalidateLibraryAfterWrite,onReconciled:reconciled,onRefreshRequired:reloadView}); scope.listen(back,'click',()=>{window.location.hash='#/library';}); @@ -352,16 +364,17 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { scope.listen(send,'click',()=>{const roles=current?.sendRoles();if(roles)sender.open(roles,send);}); function reconciled(result,context) { rememberLibraryResult(result);rememberUnappliedMetadata(result,context); - const message=outcomeMessage(result),id=result.detail.positive.id; + const message=outcomeMessage(result,context?.outcome),id=result.detail.positive.id; if(message) showWorkspaceToast(message); if(id!==positiveId) {focusHeadingOnMount=true;window.location.hash=`#/library/${id}`;return;} + pendingSelection=takeSavedSelection(positiveId); if(current) current.apply(result);else loader.load(positiveId); focusHeading(main); } /** Explicit exit after restored storage: dispose the recorded view, then remount with a fresh visit. */ function reloadView() { writeGeneration++;shelfVisit=shelfVisit && {filters:shelfVisit.filters,anchor:null}; - variantSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId); + pendingSelection=null;savedSelections.delete(positiveId);selectedPairs.delete(positiveId);metadataDrafts.delete(positiveId); current=null;contentScope?.dispose();contentScope=null;historyVisits.delete(positiveId); loader.load(positiveId);focusHeading(main); } @@ -445,14 +458,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { 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}`); lane.button.setAttribute('aria-pressed',String(activePairId===entry.variant.id));lane.button.disabled=savedReadState!=='current'; - lane.text.textContent=`${name} · ${entry.variant.image_count} linked images${entry.kind==='archived'?' · Archived':''}`; + lane.text.textContent=`${name} · ${countOf(entry.variant.image_count,'linked image')}${entry.kind==='archived'?' · Archived':''}`; 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; } if(active?.isConnected && document.activeElement!==active) active.focus({preventScroll:true}); emptyNote.hidden=entries.length>0; - variantCount.textContent=`${detail.variants.length} of ${detail.variant_total} saved negative variants`; + variantCount.textContent=`${detail.variants.length} of ${countOf(detail.variant_total,'saved negative variant')}`; const archivedState=archivedLoader.state,target=moreTarget(); const loadError=state.variantsError || (archivedShown && (archivedState.error || archivedState.variantsError)); more.hidden=!target || !!loadError;more.setAttribute('aria-disabled',String(state.loadingVariants || archivedState.loading || archivedState.loadingVariants || savedReadState!=='current'));retry.hidden=!loadError; @@ -510,7 +523,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { reloadSaved.hidden=savedReadState==='current'; } } - updateBranches(loader.state);blocks?.setState({prompts:rolesFor(variantSelections.get(positiveId)??null),actionsEnabled:savedReadState==='current'});updateSend(); + updateBranches(loader.state);blocks?.setState({prompts:rolesFor(activePairId ?? null),actionsEnabled:savedReadState==='current'});updateSend(); } async function retrySavedParent() { parentRequest?.abort();const request=parentRequest=new AbortController(),generation=++parentGeneration;setParentState({loading:true}); @@ -521,7 +534,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { setParentState({detail:fresh}); } catch(error) {if(!content.signal.aborted && generation===parentGeneration)setParentState({error});} } - content.listen(retryParent,'click',retrySavedParent);content.listen(reloadSaved,'click',()=>{variantSelections.delete(positiveId);loader.load(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=>{ if(content.signal.aborted) return;parentGeneration++;parentRequest?.abort();setParentState(parent); @@ -531,7 +544,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { function select(pairId,{force=false}={}) { if(savedReadState!=='current' || (!force && galleryScope && activePairId===pairId)) return; if(pairId!==null && !recordFor(pairId)) pairId=null; - activePairId=pairId;variantSelections.set(positiveId,pairId);blocks.setState({prompts:rolesFor(pairId),actionsEnabled:true}); + 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)); 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); @@ -563,7 +576,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { if(restoringPairs.has(pairId)) return; restoringPairs.add(pairId);showRestoreProblem('');button.disabled=true; const controller=createLibraryEditorController({api,mode:'metadata',positiveId,pairId,onCommitted:invalidateLibraryAfterWrite,onReconciled:result=>{ - rememberLibraryResult(result);apply(result); + rememberLibraryResult(result);pendingSelection=takeSavedSelection(positiveId);apply(result); const dot=[...lanes.values()].find(lane=>lane.entry.kind!=='archived' && lane.entry.variant.id===pairId)?.button;dot?.focus(); }}); content.cleanup(()=>controller.dispose()); @@ -604,12 +617,13 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } current={activePairId:()=>activePairId ?? null,sendRoles:()=>{const roles=activePairId===null || activePairId===undefined?{}:promptRoles(rolesFor(activePairId));return Object.hasOwn(roles,'positive') && Object.hasOwn(roles,'negative')?roles:null;},apply}; content.listen(all,'click',()=>select(null)); - const remembered=variantSelections.get(positiveId),outside=selectedPairs.get(positiveId); + 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); - let initial=null; + // Open on the first negative variant (concept navigate: state.variant = 0) unless a save just named a pair. + let initial=detail.variants[0]?.id ?? null; if(detail.variants.some(variant=>variant.id===remembered)) initial=remembered; - else if(outside?.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}} + else if(outside && outside.id===remembered) {try {libraryPromptRoles(detail,remembered,outside);initial=remembered;} catch {selectedPairs.delete(positiveId);}} updateBranches(state);select(initial); } loader.load(positiveId); diff --git a/web/js/workspace/plural.mjs b/web/js/workspace/plural.mjs new file mode 100644 index 0000000..160d7bf --- /dev/null +++ b/web/js/workspace/plural.mjs @@ -0,0 +1,9 @@ +/** Count wording shared by Library and history views: the singular noun only for exactly one. */ +export function noun(count, singular, plural = `${singular}s`) { + return count === 1 ? singular : plural; +} + +/** `1 linked image`, `2 linked images`. */ +export function countOf(count, singular, plural) { + return `${count} ${noun(count, singular, plural)}`; +}