From 68d501b1612d5e949b9e56400c3cc39305539f6d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Thu, 29 Oct 2026 16:24:49 -0700 Subject: [PATCH] fix(library): act on a loaded prompt only, keep focus on a gone page, close the menu on focus loss (#512) Review fixes: Edit metadata and the More menu are disabled while the prompt loads or failed to load; on a gone prompt's page a focused control hands focus to Open the Prompt library; the menu closes when focus leaves it. A unit pins that a paired prompt with a missing digest is not called another prompt's negative. --- tests/js/workspace-library-delete.test.js | 3 +++ web/js/workspace/library-delete.mjs | 1 + web/js/workspace/library.mjs | 10 +++++++--- web/js/workspace/more-menu.mjs | 2 ++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/js/workspace-library-delete.test.js b/tests/js/workspace-library-delete.test.js index 5efb1395..58cc0a3b 100644 --- a/tests/js/workspace-library-delete.test.js +++ b/tests/js/workspace-library-delete.test.js @@ -318,6 +318,9 @@ test('a negative used by another prompt is named as such when it is archived', a "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/web/js/workspace/library-delete.mjs b/web/js/workspace/library-delete.mjs index a1368a8f..1146f26a 100644 --- a/web/js/workspace/library-delete.mjs +++ b/web/js/workspace/library-delete.mjs @@ -67,6 +67,7 @@ function pairedOf(preview) { 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)) : [], }; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index e98ac592..00868e64 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -581,12 +581,16 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on 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) { - edit.disabled=true;moreMenu.setDisabled(true); 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);return; + 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; } - edit.disabled=false;moreMenu.setDisabled(false); 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 index 2d3ce061..3d6fbd0c 100644 --- a/web/js/workspace/more-menu.mjs +++ b/web/js/workspace/more-menu.mjs @@ -71,6 +71,8 @@ export function createMoreMenu({ scope, label, items }) { 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);