diff --git a/TESTING.md b/TESTING.md index f04f7094..3f9f3b36 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2928,6 +2928,34 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart. +### Delete review and prompt-page polish (#512) + +Found in the live test of #505/#507/#509. **D163** (beyond the concept, maintainer ruling 2026-10-29: delete was hard to +find on a prompt's page): the prompt page heading ends with a **"⋯" More menu** button after Send to ComfyUI +(`more-menu.mjs`: `aria-haspopup="menu"`, `aria-expanded`; Enter, Space or ArrowDown opens it on its first item, ArrowUp +on its last; arrows, Home and End move; Escape closes it and returns focus to ⋯; Tab, an outside press or a choice +closes it). It holds one danger item, "Delete prompt…", which opens the same review as Edit metadata's Delete prompt… +(D161, kept) and returns focus to ⋯. The menu uses the concept's button, surface, border and toast shadow, opens +right-aligned under ⋯, and opens rightwards where the heading wraps it to the left edge (390px). It is held with Edit +metadata while the page's review or write runs, and disabled on a gone prompt's page. + +Also: +- The Delete… review leaves out rows with nothing in them ("Delete permanently: 0 …", "Archive: 0 …"), close-only + reviews included. +- A review opened from a prompt's page is titled "Delete this prompt" or "Archive this prompt" (following the choice), + and its close-only sentence reads "This prompt cannot be deleted or archived." +- An archived prompt that is another prompt's negative is named so: "1 prompt used as another prompt's negative: + hidden, restorable" (or "N prompts in a saved pair (M used as another prompt's negative)"), with the sentence that its + variant stays. +- A prompt page whose prompt no longer exists (404, after a delete or from a stale link) reads "This prompt no longer + exists. It may have been deleted." with "Open the Prompt library", no Retry; Edit metadata and ⋯ are disabled. +- Item 5 of #512 does not fail on the code after #505: the archived shelf's button reads "← Prompt library", and under a + remembered search the heading's count equals what the archived shelf lists. A regression test pins both. + +Tests: `tests/js/workspace-library-delete.test.js` (#512) and `tests/browser/workspace-library-delete.spec.mjs` (#512 +cases: the menu by mouse, keyboard and at 390px, the gone page, the titles, the count under a search). JS only: no +restart. + ### No 1–5 prompt rating in the workspace (#447) **D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 9234a50d..19edf093 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -421,7 +421,8 @@ export function measureInventory(scope) { description:when(description,()=>({className:description.className,text:description.textContent,...font(description),...edges(description)})), actions:when(actions,()=>({...layout(actions),...edges(actions), fullWidth:px(rect(actions).width)===px(rect(heading).width),stacked:copy?rect(actions).top>=rect(copy).bottom-0.5:null, - buttons:[...actions.querySelectorAll('button')].map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'), + // D163: a closed menu's items are not heading actions. + buttons:[...actions.querySelectorAll('button')].filter(node=>!node.closest('[role="menu"]')).map(node=>({text:node.textContent.trim(),primary:node.classList.contains('ca-primary'), ...font(node),...edges(node),minHeight:getComputedStyle(node).minHeight,height:px(rect(node).height)}))}))}; } // ---- Images (#249 C7). Every box is relative to its section, every section to `main` (vertical rhythm). ---- diff --git a/tests/browser/history-concept.spec.mjs b/tests/browser/history-concept.spec.mjs index b58499e1..ffd96385 100644 --- a/tests/browser/history-concept.spec.mjs +++ b/tests/browser/history-concept.spec.mjs @@ -26,7 +26,8 @@ const SECTIONS=['heading','history','noPageScroll']; */ export function mapHistoryConcept(reference) { const {history}=reference.oracle; - return {...reference,history:{...history, + // D163 (#512): the heading as laid out with the More menu button (the oracle's heading). + return {...reference,heading:reference.oracle.heading,history:{...history, // D24: "N tracked runs" is the recorded window's `recorded_run_count`; the fixture records one run per image, so // the concept's "4 tracked runs" holds by construction (asserted on both documents below). summary:history.summary, @@ -120,11 +121,14 @@ async function conceptAsRecorded(page) { const add=[...document.querySelectorAll('.timeline-panel .row-actions > button')].find(node=>node.textContent==='+ Add negative'); const archived=add.cloneNode(false);archived.removeAttribute('data-action');archived.removeAttribute('data-prompt'); archived.setAttribute('aria-pressed','false');archived.textContent=label;add.after(archived); - window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived}; + // D163 (#512): the heading ends with the "⋯" More menu button, drawn as the concept's Edit metadata button. + const edit=[...document.querySelectorAll('#main > .heading .heading-actions > button')].find(node=>node.textContent.trim()==='Edit metadata'); + const moreButton=edit.cloneNode(false);moreButton.removeAttribute('data-action');moreButton.textContent='⋯';edit.parentElement.append(moreButton); + window.__historyOracle={timeline,order:[...lanes].reverse(),texts,archived,moreButton}; },SHOW_ARCHIVED); await settle(page); return ()=>page.evaluate(()=>{ - const {timeline,order,texts,archived}=window.__historyOracle; + const {timeline,order,texts,archived,moreButton}=window.__historyOracle;moreButton.remove(); order.forEach((lane,index)=>{lane.style.setProperty('--lane-offset',`${38+index*47}px`);timeline.append(lane);}); for(const [span,text] of texts)span.textContent=text; archived.remove();delete window.__historyOracle; diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index 36ca84a4..d18d8ac8 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -50,7 +50,8 @@ test('Delete… reviews the selection, deletes the unpaired, archives the paired await expect(review(page)).toHaveAccessibleName('Delete or archive selected prompts'); const grid=review(page).locator('dl.meta-grid > div'); await expect(grid.locator('dt')).toHaveText(['Delete permanently','Archive','Protected (kept)','Image links removed','Backup']); - await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair','1 prompt in a saved pair: hidden, restorable', + // #512: Cat portrait is also another prompt's negative in the fixture, so the review says so. + await expect(grid.locator('dd')).toHaveText(['1 prompt not in a saved pair',"1 prompt used as another prompt's negative: hidden, restorable", '1: Library extra 0000','0 images will no longer show these prompts (files stay on disk)','Create and verify before deleting']); await expect(review(page).locator('p.small')).toHaveCount(3); await expect(review(page).getByRole('button',{name:'Delete 1 and archive 1 prompt',exact:true})).toHaveClass(/danger/); @@ -81,7 +82,7 @@ test('Show archived prompts lists archived prompts, and Restore on its bar retur await openReview(page); await expect(review(page)).toHaveAccessibleName('Archive selected prompts'); // The archive reason is the saved pair, for one or many prompts (live note on #470): never negative variants. - await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText('2 prompts in a saved pair: hidden, restorable'); + await expect(review(page).locator('dl.meta-grid > div').filter({hasText:'Archive'}).locator('dd')).toHaveText("2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"); await review(page).getByRole('button',{name:'Archive 2 prompts',exact:true}).click(); await expect(toast(page)).toHaveText('Archived 2 prompts.'); await expect(cards(page)).toHaveCount(before.length-2); @@ -300,7 +301,8 @@ test('the page\'s delete holds one review at a time, Escape returns focus, and a await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click(); await expect(page.locator('.library-archived-notice')).toBeVisible(); await editMetadata(page).click();await editor(page).getByRole('button',{name:'Delete prompt…',exact:true}).click(); - await expect(review(page).locator('p.small').last()).toHaveText('None of the selected prompts can be deleted or archived.'); + // #512: the page's review names its one prompt. + await expect(review(page).locator('p.small').last()).toHaveText('This prompt cannot be deleted or archived.'); await expect(review(page).getByRole('button',{name:/^(Delete|Archive)/})).toHaveCount(0); }); @@ -459,3 +461,94 @@ test('a failed review keeps the selection, and a selection nothing can change op await expect(bar(page).getByRole('button',{name:'Delete…',exact:true})).not.toHaveAttribute('aria-disabled','true'); expect(byText(await items(request),'Library extra 0000')).toBeDefined(); }); + +// #512 (live test of #505/#507/#509): the prompt page's "⋯" More menu (D163), a gone prompt's page, single-prompt titles. +const more=page=>page.locator('#workspace-main .heading-actions').getByRole('button',{name:'More actions for this prompt',exact:true}); +const moreMenu=page=>page.getByRole('menu',{name:'More actions for this prompt'}); +test('the prompt page\'s More menu opens Delete prompt…, by mouse and keyboard, and closes on Escape or outside (#512 D163)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + await page.goto(`/prompt_manager/workspace#/library/${dual.id}`); + const heading=page.locator('#workspace-main .heading-actions > button, #workspace-main .heading-actions > .more-menu > button'); + await expect(heading).toHaveText(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']); + await expect(more(page)).toHaveAttribute('aria-haspopup','menu');await expect(more(page)).toHaveAttribute('aria-expanded','false'); + await expect(moreMenu(page)).toBeHidden(); + // Keyboard: Enter opens on the first item; Escape closes and returns focus. + await more(page).focus();await page.keyboard.press('Enter'); + await expect(more(page)).toHaveAttribute('aria-expanded','true'); + const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true}); + await expect(item).toBeFocused();await expect(item).toHaveClass(/\bdanger\b/); + await page.keyboard.press('ArrowDown');await expect(item).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toBeFocused();await expect(more(page)).toHaveAttribute('aria-expanded','false'); + // ArrowUp opens on the last item; ArrowUp, Home and End move within it (one item here); Tab closes it. + await page.keyboard.press('ArrowUp');await expect(item).toBeFocused(); + for(const key of ['ArrowUp','Home','End']) {await page.keyboard.press(key);await expect(item).toBeFocused();} + await page.keyboard.press('Tab');await expect(moreMenu(page)).toBeHidden();await expect(more(page)).toHaveAttribute('aria-expanded','false'); + // Space opens too; an outside press closes it. + await more(page).focus();await page.keyboard.press(' ');await expect(item).toBeFocused(); + await page.locator('#workspace-main h1').click();await expect(moreMenu(page)).toBeHidden(); + // Choosing it opens the page's review, titled for this prompt; Cancel returns focus to ⋯. + await more(page).click();await item.click(); + await expect(moreMenu(page)).toBeHidden(); + await expect(review(page)).toHaveAccessibleName('Archive this prompt'); + await expect(choice(page).getByRole('radio',{name:'Archive (hidden, restorable)'})).toBeChecked(); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await expect(review(page).locator('#modal-title')).toHaveText('Delete this prompt'); + await review(page).getByRole('button',{name:'Cancel',exact:true}).click(); + await expect(review(page)).toHaveCount(0);await expect(more(page)).toBeFocused(); + expect(byText(await items(request),'Dual role portrait')?.id).toBe(dual.id); +}); + +test('the More menu fits a 390px viewport and works by touch (#512 D163)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + await page.setViewportSize({width:390,height:844}); + await page.goto(`/prompt_manager/workspace#/library/${dual.id}`); + await more(page).click(); + const item=moreMenu(page).getByRole('menuitem',{name:'Delete prompt…',exact:true}); + await expect(item).toBeVisible(); + const box=await moreMenu(page).boundingBox(); + expect(box.x).toBeGreaterThanOrEqual(0);expect(box.x+box.width).toBeLessThanOrEqual(390); + await item.click(); + await review(page).getByRole('button',{name:'Archive 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText('Archived 1 prompt.'); + await expect(page.locator('.library-archived-notice')).toBeVisible(); +}); + +test('a deleted prompt\'s page says it no longer exists, with the way back and no Retry (#512)',async({page,request})=>{ + const dual=byText(await items(request),'Dual role portrait'); + await deleteFromPage(page,dual.id); + await expect(review(page)).toHaveAccessibleName('Archive this prompt'); + await choice(page).getByRole('radio',{name:'Delete permanently, with their saved pairs'}).check(); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(page).toHaveURL(/#\/library$/); + await page.goto(`/prompt_manager/workspace#/library/${dual.id}`); + await expect(page.locator('#workspace-main .library-status')).toHaveText('This prompt no longer exists. It may have been deleted.'); + await expect(page.getByRole('button',{name:'Retry prompt history'})).toHaveCount(0); + await expect(editMetadata(page)).toBeDisabled();await expect(more(page)).toBeDisabled(); + await page.getByRole('button',{name:'Open the Prompt library',exact:true}).click(); + await expect(page).toHaveURL(/#\/library$/); +}); + +test('the archived shelf\'s heading count and its list agree under a remembered search (#512 item 5)',async({page,request})=>{ + const all=await items(request); + const dual=byText(all,'Dual role portrait'),dog=byText(all,'Dog landscape'); + await archiveByApi(request,[dual.id]); + await page.goto(route); + // A search that matches a shelf prompt but not the archived one. + await page.getByRole('searchbox').fill('landscape'); + await expect(card(page,dog.id)).toHaveCount(1);await expect(card(page,dual.id)).toHaveCount(0); + const matching=(await(await request.get('/prompt_manager/library?limit=100&shelf=archived&text=landscape')).json()).items.length; + await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`); + // Leave and come back: the remembered search is shown and still counts the same. + await page.goto('/prompt_manager/workspace#/overview');await page.goto(route); + await expect(page.getByRole('searchbox')).toHaveValue('landscape'); + await expect(showArchived(page)).toHaveText(`Show archived prompts (${matching})`); + await showArchived(page).click(); + await expect(backToLibrary(page)).toBeVisible(); + if(matching) await expect(cards(page)).toHaveCount(matching); + else await expect(page.locator('.library-status')).toHaveText('No archived prompts match.'); + await page.getByRole('searchbox').fill(''); + await expect(card(page,dual.id)).toHaveCount(1); + await backToLibrary(page).click(); + await expect(showArchived(page)).toHaveText('Show archived prompts (1)'); +}); diff --git a/tests/browser/workspace-library-editor.spec.mjs b/tests/browser/workspace-library-editor.spec.mjs index 0d4018dd..cc858a0a 100644 --- a/tests/browser/workspace-library-editor.spec.mjs +++ b/tests/browser/workspace-library-editor.spec.mjs @@ -540,8 +540,9 @@ test('new from Images cancel preserves browse',async({page})=>{ test('history heading follows the concept and sends the selected saved pair',async({page})=>{ const f=page.fixture;await openHistory(page,f.positive_id); - const actions=headingActions(page).locator('button'); - expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI']); + const actions=headingActions(page).locator('button:not([role="menuitem"])'); + // D163 (#512): the More menu button follows Send to ComfyUI. + expect(await visibleNames(actions)).toEqual(['← Prompt library','Edit metadata','Send to ComfyUI','⋯']); await expect(page.getByRole('link',{name:'Back to library',exact:true})).toHaveCount(0); const send=actions.nth(2);await expect(send).toHaveClass(/\bsend\b/);await expect(send.locator('svg')).toHaveCount(1); await expect(send).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${f.metadata.title}`); diff --git a/tests/js/workspace-library-delete.test.js b/tests/js/workspace-library-delete.test.js index 991cead6..58cc0a3b 100644 --- a/tests/js/workspace-library-delete.test.js +++ b/tests/js/workspace-library-delete.test.js @@ -42,8 +42,13 @@ test('the grid shows each row only when it applies, with protected titles in sel ['Image links removed', '3 images will no longer show these prompts (files stay on disk)'], ['Backup', 'Create and verify before deleting'], ]); + // #512: a row with nothing in it is left out. const archiveOnly = reviewOf(preview({archive: [2]})); - assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Delete permanently', 'Archive']); + assert.deepEqual(reviewGrid(archiveOnly).map(([term]) => term), ['Archive']); + const deleteOnly = reviewOf(preview({delete: [1]})); + assert.deepEqual(reviewGrid(deleteOnly).map(([term]) => term), ['Delete permanently', 'Image links removed', 'Backup']); + const keptOnly = reviewOf(preview({protected: [7]})); + assert.deepEqual(reviewGrid(keptOnly, id => names[id]), [['Protected (kept)', '1: Seven']]); const off = reviewOf(preview({delete: [1], backup: 'off'})); assert.equal(reviewGrid(off).at(-1)[1], 'Off: no safety backup (System › Backup & export › Recovery policy)'); }); @@ -136,7 +141,7 @@ test('reviewOf keeps only well-formed ids and counts from an answer', async () = not_found: 'x', image_links: 2, backup: 'maybe', not_archived: -3}); assert.deepEqual(review, {shelf: 'active', notArchived: 0, epoch: null, delete: [1, 2], archive: [], protected: [], alreadyArchived: 0, notFound: 0, imageLinks: 2, backup: 'on', disk: null, deletable: [], digests: {}, - pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive'}); + pairedCounts: {pairs: 0, archivedPairs: 0, variants: 0, keptNegatives: 0}, pairedImageLinks: 0, choice: 'archive', archiveOnly: []}); }); test('the archived notice says why (the saved pair) and names negative variants only when there are any', async () => { @@ -291,3 +296,32 @@ test('the outcome names prompts restored since the review', async () => { assert.equal(none.toast, 'Nothing was deleted. 1 was restored since the review and kept. 1 changed since the review and ' + 'was not deleted. The Library was reloaded.'); }); + +// #512 (live test of #505/#507/#509): the review names a single prompt, and a negative selected without its positive. +test('a review from a prompt\'s own page names that prompt', async () => { + const {reviewOf, reviewTitle, blockedReason} = await source; + const single = review => ({...review, single: true}); + assert.equal(reviewTitle(single(reviewOf(preview({delete: [1]})))), 'Delete this prompt'); + assert.equal(reviewTitle(single(reviewOf(preview({archive: [2]})))), 'Archive this prompt'); + const kept = single(reviewOf(preview({protected: [3]}))); + assert.equal(reviewTitle(kept), 'Delete this prompt'); + assert.equal(blockedReason(kept), 'This prompt cannot be deleted or archived.'); + assert.equal(reviewTitle(single(reviewOf(preview({archive: [2], deletable: [2], review_digests: {2: 'c'.repeat(64)}}), + 'archived'))), 'Delete this prompt'); +}); + +test('a negative used by another prompt is named as such when it is archived', async () => { + const {reviewOf, reviewGrid, reviewSentences} = await source; + const negative = reviewOf(preview({archive: [5], deletable: []})); + assert.deepEqual(reviewGrid(negative), [['Archive', "1 prompt used as another prompt's negative: hidden, restorable"]]); + assert.deepEqual(reviewSentences(negative), ["Prompts used as another prompt's negative are archived instead of deleted, so " + + "that prompt's variant stays. To bring them back, press Show archived prompts, select them and press Restore."]); + const mixed = reviewOf(preview({archive: [2, 5], deletable: [2], review_digests: {2: 'c'.repeat(64)}})); + assert.deepEqual(reviewGrid(mixed)[0], ['Archive', "2 prompts in a saved pair (1 used as another prompt's negative): hidden, restorable"]); + // A paired prompt whose review digest is missing is not called another prompt's negative (review M1). + const noDigest = reviewOf(preview({archive: [2], deletable: [2], review_digests: {}})); + assert.deepEqual(reviewGrid(noDigest), [['Archive', '1 prompt in a saved pair: hidden, restorable']]); + const sentences = reviewSentences(mixed); + assert.match(sentences[0], /^Prompts in a saved pair, as its positive or its negative, are archived instead of deleted/); + assert.match(sentences[1], /^Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant stays\.$/); +}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 8da3fe11..33a431e5 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -491,6 +491,12 @@ "functions": 98, "statements": 98 }, + "workspace/more-menu.mjs": { + "lines": 80, + "branches": 70, + "functions": 80, + "statements": 80 + }, "workspace/plural.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index b658152d..1146f26a 100644 --- a/web/js/workspace/library-delete.mjs +++ b/web/js/workspace/library-delete.mjs @@ -26,6 +26,7 @@ export const COPY = Object.freeze({ nothing: `${NOTHING} The Library was reloaded.`, none: 'None of the selected prompts can be deleted or archived.', noneArchived: 'None of the selected prompts can be deleted.', + noneSingle: 'This prompt cannot be deleted or archived.', restoreStorage: 'The library was restored or moved since this page loaded. No prompt was returned to the Library.', restoreUnconfirmed: 'Returning the prompts could not be confirmed. The Library was reloaded.', restoreFailed: 'The prompts could not be returned to the Library. Try again.', @@ -65,6 +66,10 @@ function pairedOf(preview) { pairedCounts: { pairs: count(counts.pairs), archivedPairs: count(counts.archived_pairs), variants: count(counts.variants), keptNegatives: count(counts.kept_negatives) }, pairedImageLinks: count(preview?.paired_image_links), choice: 'archive', + // #512: archive ids the server says cannot be deleted (another prompt's negative); none from an older answer. + // The server's own list, not the digest-checked one: a deletable prompt with a bad digest is still paired. + archiveOnly: Array.isArray(preview?.deletable) + ? [...archive].filter(id => !ids(preview.deletable).includes(id)) : [], }; } @@ -87,6 +92,8 @@ function plan(review) { export function reviewTitle(review) { const { plain, paired, archive } = plan(review), deletes = plain.length + paired.length; + // #512: a review opened from a prompt's own page names that prompt. + if (review.single) return deletes || !archive.length ? 'Delete this prompt' : 'Archive this prompt'; if (onArchivedShelf(review)) return 'Delete archived prompts'; if (!archive.length) return 'Delete selected prompts'; if (!deletes) return 'Archive selected prompts'; @@ -112,16 +119,23 @@ export function archivedNoticeText(variantCount) { /** The review's `[term, value]` rows (design §7.2); rows that do not apply are left out. */ export function reviewGrid(review, titleOf = () => null) { if (permanent(review)) return permanentGrid(review, titleOf); - const rows = [ - ['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`], - // The archive reason is the saved pair (a pair's positive or negative), with or without negative variants. - ['Archive', `${countOf(review.archive.length, 'prompt')} in a saved pair: hidden, restorable`], - ]; + // #512: rows with nothing in them are left out. + const rows = []; + if (review.delete.length) rows.push(['Delete permanently', `${countOf(review.delete.length, 'prompt')} not in a saved pair`]); + if (review.archive.length) rows.push(['Archive', archiveValue(review)]); keptRows(rows, review, titleOf); if (review.delete.length) deleteRows(rows, review, review.imageLinks); return rows; } +/** The archive reason is the saved pair (a pair's positive or negative); #512 names another prompt's negative. */ +function archiveValue(review) { + const total = review.archive.length, negatives = review.archiveOnly.length; + if (negatives === total) return `${countOf(total, 'prompt')} used as another prompt's negative: hidden, restorable`; + const detail = negatives ? ` (${negatives.toLocaleString('en-US')} used as another prompt's negative)` : ''; + return `${countOf(total, 'prompt')} in a saved pair${detail}: hidden, restorable`; +} + function keptRows(rows, review, titleOf) { if (review.protected.length) { const names = protectedNames(review, titleOf); @@ -163,12 +177,17 @@ function permanentGrid(review, titleOf) { /** The review's sentences, in order, each when it applies. */ export function reviewSentences(review) { if (permanent(review)) return permanentSentences(review); - const sentences = []; - if (review.archive.length) { + const sentences = [], negatives = review.archiveOnly.length; + if (review.archive.length > negatives) { sentences.push('Prompts in a saved pair, as its positive or its negative, are archived instead of deleted, so the ' + 'pair and its runs stay intact. To delete them later or bring them back, press Show archived prompts, select them ' + 'and press Delete… or Restore.'); } + // #512: a negative selected without its positive stays the other prompt's variant. + if (negatives) { + sentences.push("Prompts used as another prompt's negative are archived instead of deleted, so that prompt's variant " + + `stays.${negatives === review.archive.length ? ' To bring them back, press Show archived prompts, select them and press Restore.' : ''}`); + } if (review.delete.length) sentences.push(removeSentence(review)); if (review.protected.length) sentences.push(PROTECTED_SENTENCE); return sentences; @@ -210,8 +229,9 @@ export function primaryLabel(review) { /** Why a review cannot start, or null: nothing to change, or no room for the promised backup. */ export function blockedReason(review) { - if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return COPY.noneArchived; - if (!review.delete.length && !review.archive.length) return COPY.none; + const none = review.single ? COPY.noneSingle : null; + if (onArchivedShelf(review) && !review.delete.length && !review.deletable.length) return none ?? COPY.noneArchived; + if (!review.delete.length && !review.archive.length) return none ?? COPY.none; const deletes = review.delete.length + plan(review).paired.length; if (deletes && review.disk && review.disk.sufficient === false) { return typeof review.disk.message === 'string' && review.disk.message @@ -372,7 +392,8 @@ export async function reviewPromptDelete({ api, scope, trigger, ids: promptIds, } if (scope.signal.aborted) return; if (failure !== null || !preview?.success) { onSettled(previewOutcome(failure), null, failure); return; } - const review = { ...reviewOf(preview), epoch }; + // #512: the review names this one prompt. + const review = { ...reviewOf(preview), epoch, single: true }; openDeleteReview({ scope, trigger, review, titleOf, onDismiss, onStart: async chosen => { let result = null, error = null; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index f9190deb..4b221ca1 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -18,8 +18,11 @@ import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs'; import {mountLibrarySelection} from './library-selection.mjs'; import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs'; import {COPY as DELETE_COPY,RESTORE_URL,archivedNoticeText,restoreOutcome,reviewPromptDelete} from './library-delete.mjs'; +import {createMoreMenu} from './more-menu.mjs'; let shelfVisit=null; +// #512: what a prompt page says when its prompt no longer exists (404). +const PROMPT_GONE='This prompt no longer exists. It may have been deleted.'; // Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream. let writeGeneration=0, focusHeadingOnMount=false, savedParentId=null; // The pair one reconciled save names, taken once by the history it opens (the concept selects it after a save, app.js 986). @@ -488,7 +491,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on headingDescription(main,history,'Prompt history · one positive, one shared gallery'); const actions=headingActions(main,history); const back=createButton('← Prompt library'),edit=createButton('Edit metadata'),send=createButton('','send'); - send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true;actions.append(back,edit,send); + send.append(createWorkspaceIcon('send'),el('span','Send to ComfyUI'));send.disabled=true; + // D163 (#512, maintainer 2026-10-29): a "⋯" More menu after Send to ComfyUI holds Delete prompt…, so delete is + // findable on the page; Edit metadata keeps its own Delete prompt… (D161). + const moreMenu=createMoreMenu({scope,label:'More actions for this prompt',items:[{label:'Delete prompt…',danger:true,onSelect:()=>deletePrompt(moreMenu.button)}]}); + actions.append(back,edit,send,moreMenu.element); 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); @@ -504,14 +511,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // can race the delete; aria-disabled keeps it focusable for the return. function holdEdit(held) { deleting=held; - if(held) edit.setAttribute('aria-disabled','true');else edit.removeAttribute('aria-disabled'); + for(const button of [edit,moreMenu.button]) { + if(held) button.setAttribute('aria-disabled','true');else button.removeAttribute('aria-disabled'); + } } - function deletePrompt() { + function deletePrompt(trigger=edit) { const shown=detail;if(deleting || !shown?.positive) return; // One review at a time, as the selection bar holds itself (a second press during the preview starts nothing). holdEdit(true); - reviewPromptDelete({api,scope,trigger:edit,ids:[shown.positive.id],epoch:shown.library_epoch, - titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(edit.isConnected) edit.focus({preventScroll:true});}, + reviewPromptDelete({api,scope,trigger,ids:[shown.positive.id],epoch:shown.library_epoch, + titleOf:()=>displayTitle(shown.positive),onDismiss:()=>{holdEdit(false);if(trigger.isConnected) trigger.focus({preventScroll:true});}, onSettled:(outcome,result,failure)=>{ holdEdit(false); if(outcome.committed) invalidateLibraryAfterWrite({kind:'bulk-delete',receipt:result ?? {phase:'unconfirmed'}}); @@ -571,6 +580,17 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on if(detail && state.detail && !state.loading && !state.error) {detail=state.detail;updateParent();updateBranches(state);return;} contentScope?.dispose();contentScope=createScope();detail=null;updateBranches=null;updateParent=null;current=null;send.disabled=true; blocks?.dispose();blocks=null;galleryScope?.dispose();galleryScope=null;body.replaceChildren(); + // #512: a prompt that no longer exists (deleted, or a stale link) says so, with the way back; no Retry. + // Edit metadata and ⋯ act on a loaded prompt only (review M2). + const hadFocus=[edit,moreMenu.button].includes(document.activeElement); + const loaded=!state.loading && !state.error;edit.disabled=!loaded;moreMenu.setDisabled(!loaded); + if(state.error?.status===404) { + const message=el('p',PROMPT_GONE,'library-status');message.setAttribute('role','status');body.append(message); + const open=createButton('Open the Prompt library');contentScope.listen(open,'click',()=>{window.location.hash='#/library';});body.append(open); + // A focused control that was just disabled hands focus to the way back (review M3). + if(hadFocus) open.focus({preventScroll:true}); + return; + } if(state.loading || state.error) { const message=el('p',state.loading?'Loading prompt history…':'Prompt history could not be loaded.','library-status');message.setAttribute('role',state.error?'alert':'status');body.append(message); if(state.error) {const retry=createButton('Retry prompt history');contentScope.listen(retry,'click',()=>loader.load(positiveId));body.append(retry);}return; diff --git a/web/js/workspace/more-menu.mjs b/web/js/workspace/more-menu.mjs new file mode 100644 index 00000000..664cf7ec --- /dev/null +++ b/web/js/workspace/more-menu.mjs @@ -0,0 +1,85 @@ +import { createButton } from '../shared/toolkit/core.js'; + +/** + * A "⋯" (More) menu button for a page heading (D163, #512; the concept has no menu, so it uses the concept's button + * and surface styles). The button carries `aria-haspopup="menu"` and `aria-expanded`; Enter, Space or ArrowDown opens + * the menu on its first item, ArrowUp on its last; ArrowUp/ArrowDown/Home/End move between items; Escape closes it and + * returns focus to the button; Tab, an outside pointer press or choosing an item closes it. A held button + * (`aria-disabled`) or a disabled one opens nothing. Listeners live on `scope`. + * + * `items`: `[{ label, danger, onSelect }]`. Returns `{ element, button, setDisabled(disabled) }`. + */ +let menus = 0; +export function createMoreMenu({ scope, label, items }) { + const id = `more-menu-${++menus}`; + const element = document.createElement('div'); + element.className = 'more-menu'; + const button = createButton('⋯'); + button.classList.add('more-menu-button'); + button.setAttribute('aria-label', label); + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', 'false'); + button.setAttribute('aria-controls', id); + const menu = document.createElement('div'); + menu.className = 'more-menu-list'; + menu.id = id; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', label); + menu.hidden = true; + const entries = items.map(item => { + const entry = createButton(item.label, item.danger ? 'danger' : ''); + entry.setAttribute('role', 'menuitem'); + entry.tabIndex = -1; + scope.listen(entry, 'click', () => { close(false); item.onSelect(); }); + return entry; + }); + menu.append(...entries); + element.append(button, menu); + + const isOpen = () => !menu.hidden; + const held = () => button.disabled || button.getAttribute('aria-disabled') === 'true'; + function open(index) { + if (held()) return; + menu.hidden = false; + // Right-aligned under the button; where the heading wraps it to the left edge (390px), it opens rightwards. + menu.style.left = menu.style.right = ''; + if (menu.getBoundingClientRect().left < 0) Object.assign(menu.style, { left: '0', right: 'auto' }); + button.setAttribute('aria-expanded', 'true'); + entries.at(index)?.focus(); + } + function close(refocus) { + if (!isOpen()) return; + menu.hidden = true; + button.setAttribute('aria-expanded', 'false'); + if (refocus) button.focus(); + } + function move(step) { + const at = entries.indexOf(document.activeElement); + entries[(at + step + entries.length) % entries.length].focus(); + } + scope.listen(button, 'click', () => (isOpen() ? close(true) : open(0))); + scope.listen(button, 'keydown', event => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + event.preventDefault(); + open(event.key === 'ArrowDown' ? 0 : -1); + }); + scope.listen(menu, 'keydown', event => { + const keys = { ArrowDown: () => move(1), ArrowUp: () => move(-1), Home: () => entries[0].focus(), + End: () => entries.at(-1).focus(), Escape: () => close(true) }; + if (event.key === 'Tab') { close(false); return; } + if (!keys[event.key]) return; + event.preventDefault(); + keys[event.key](); + }); + // Focus leaving the menu (a screen reader's cursor, a script) closes it too. + scope.listen(element, 'focusout', event => { if (event.relatedTarget && !element.contains(event.relatedTarget)) close(false); }); + // An outside press closes it; capture, so a press that a page control stops still counts. + const outside = event => { if (isOpen() && !element.contains(event.target)) close(false); }; + document.addEventListener('pointerdown', outside, true); + scope.cleanup(() => document.removeEventListener('pointerdown', outside, true)); + return { + element, + button, + setDisabled(disabled) { button.disabled = disabled; if (disabled) close(false); }, + }; +} diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index b421d847..c602f8b5 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -482,3 +482,14 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc .tagging-modal .tag-action-notice { flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin: 0; color: var(--ca-text); font-size: 12px; /* D147 */ } .tagging-modal .tag-action-notice > span { flex: 1 1 16em; } .tag-image-preview.tag-preview-empty { display: grid; place-items: center; padding: 8px; text-align: center; font-size: 12px; color: var(--ca-muted); background: var(--ca-field); box-sizing: border-box; /* D147 */ } + +/* D163 (#512): the prompt page's "⋯" More menu. The concept has no menu; it uses the concept's button, surface, border + and toast shadow, opens under its button, right-aligned so it stays inside a 390px viewport. */ +.more-menu { position: relative; display: inline-flex; } +.more-menu-list { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 20; display: flex; flex-direction: column; gap: 4px; + min-width: 160px; padding: 6px; background: var(--ca-surface); border: 1px solid var(--ca-border); border-radius: 6px; + box-shadow: 0 8px 35px #0004; +} +.more-menu-list[hidden] { display: none; } +.more-menu-list > button { justify-content: flex-start; text-align: left; width: 100%; white-space: nowrap; }