From e472a8bf747a23189a3c21c581a02856232d0cbe Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 28 Oct 2026 02:03:22 +0000 Subject: [PATCH 1/3] fix(system): Tagging selects show their whole chosen option at 390 px (#398) (#399) * fix(system): Tagging selects show their whole chosen option at 390 px (#398) The concept's phone cap (120px) cut Existing tags ("Replace after review"). #tag-existing now never shrinks for its row text and keeps its natural width at phone width, beside the text. The comparison expects selectsFit for every select, and a new test walks every option of both selects at 390 and 320 px in both themes. TESTING.md D132 records the decision. * fix(system): bound Existing tags by its row at phone width (#398 review) --- TESTING.md | 16 ++++--- tests/browser/system-tagging-concept.spec.mjs | 45 ++++++++++++------- web/js/workspace/workspace.css | 5 ++- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/TESTING.md b/TESTING.md index ce4bd6b2..b21f77c9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -3200,12 +3200,16 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati again"; an Unload that found nothing loaded only reads the status again. At phone width the concept's 120px select cap cuts every engine label, so `#tag-model` alone keeps its natural width there and its row wraps the select under the text (`workspace.css` - `/* D132 */`); Existing tags stays as the concept draws it, cut at 390 as there. The - comparison maps `selectsFit` only for the engine select and measures each row's - `flexWrap` and whether its control sits under the text. Pinned by `auto-tagging matches - the concept` (both themes and sizes), `auto-tagging at 390 wraps only the engine row`, - `auto-tagging engine select fits at 320 wide` and the mutation check (it includes a - wrapped row) (system-tagging-concept.spec), and the model, Download, Unload and Preview + `/* D132 */`). The concept's cap cuts Existing tags too ("Replace after review"); by the + maintainer's decision (#398) that parity is not kept: `#tag-existing` never shrinks for + its row text and at phone width keeps its natural width beside the text (its row does + not wrap; the text column narrows). 1600 is as the concept draws it. The comparison + expects `selectsFit` true for every select and measures each row's `flexWrap` and + whether its control sits under the text. Pinned by `auto-tagging matches the concept` + (both themes and sizes), `auto-tagging at 390 wraps only the engine row`, `auto-tagging + selects fit every option at 390/320 wide` (every option of both selects, both themes, no + page scroll) and the mutation check (a wrapped row, a squeezed engine and Existing tags + select) (system-tagging-concept.spec), and the model, Download, Unload and Preview tests of workspace-system-tagging.spec. - **D133** (Auto-tagging, S11): a "Suggestions per image" row (`#tag-suggestions`, number 1–100, host key `autotag_suggestion_limit`) follows the two confidence rows: diff --git a/tests/browser/system-tagging-concept.spec.mjs b/tests/browser/system-tagging-concept.spec.mjs index b02f5a83..ff79595c 100644 --- a/tests/browser/system-tagging-concept.spec.mjs +++ b/tests/browser/system-tagging-concept.spec.mjs @@ -127,9 +127,9 @@ function mapTaggingConcept(reference, { template, phone }) { actions: { ...reference.actions, buttons: [preview, ['Download again', ''], ['Unload', '']] }, // D134: the prompt-tag batch, reviewed first. operations: [{ tree: ['div', 'div.row-actions'], title: 'Tag output images', description: BATCH, actions: [['Review', '']] }], - // D132: the engine select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts it; - // `#tag-model` alone may grow there). Existing tags keeps the concept's own fit, cut or not. - selectsFit: reference.selectsFit.map((fit, index) => (index === 0 ? true : fit)), + // D132 (#398): every select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts + // them; production lets each grow past it). + selectsFit: reference.selectsFit.map(() => true), }; } @@ -168,16 +168,28 @@ for (const theme of THEMES) for (const [width, height] of SIZES) { }); } -// D132 below the concept's smallest size: on a 320px phone the engine select still shows its whole option. -for (const theme of THEMES) test(`auto-tagging engine select fits at 320 wide ${theme}`, async ({ page }) => { - await page.setViewportSize({ width: 320, height: 700 }); - const production = await productionTagging(page, theme); - for (const model of ['hf', 'gguf', 'wd14-swinv2']) { - await page.locator('#workspace-main #tag-model').selectOption(model); - expect((await page.evaluate(measureTagging, '#workspace-main')).selectsFit[0], model).toBe(true); - } - expect(production.pageScroll).toBe(false); -}); +// D132 (#398) at phone width and below the concept's smallest size: every option of every select, once chosen, shows +// whole, with no page scroll. +for (const theme of THEMES) for (const width of [390, 320]) { + test(`auto-tagging selects fit every option at ${width} wide ${theme}`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 844 }); + await productionTagging(page, theme); + await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review'); + await page.locator('#workspace-main section.surface').screenshot({ path: testInfo.outputPath(`production-tagging-selects-${theme}-${width}.png`) }); + const selects = page.locator('#workspace-main section.surface > .setting-row > select'); + await expect(selects).toHaveCount(2); + for (let index = 0; index < 2; index += 1) { + const select = selects.nth(index); + const values = await select.evaluate(node => [...node.options].map(option => option.value)); + for (const value of values) { + await select.selectOption(value); + const measured = await page.evaluate(measureTagging, '#workspace-main'); + expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true]); + expect(measured.pageScroll, value).toBe(false); + } + } + }); +} test('auto-tagging comparison detects mutations', async ({ page, request }) => { await page.setViewportSize({ width: 1600, height: 1100 }); @@ -222,6 +234,9 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => { const undoSqueeze = await mutate(page, { css: '#workspace-main #tag-model { max-width: 60px !important; min-width: 0 !important; }' }); expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.0'])); await undoSqueeze(); + const undoSqueezeExisting = await mutate(page, { css: '#workspace-main #tag-existing { max-width: 60px !important; min-width: 0 !important; }' }); + expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.1'])); + await undoSqueezeExisting(); // A row that wraps where the concept's does not. const undoWrap = await mutate(page, { css: '#workspace-main .setting-row:has(> #tag-existing) { flex-wrap: wrap; }' }); expect(await paths()).toEqual(['rows.1.flexWrap']); @@ -233,14 +248,14 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => { expect(diffInventory(expected, await measure())).toEqual([]); }); -// D132 at phone width, unmapped: only the engine row wraps; Existing tags keeps the concept's cut select. +// D132 at phone width: only the engine row wraps; Existing tags grows beside its text instead (#398). test('auto-tagging at 390 wraps only the engine row', async ({ page, request }) => { await page.setViewportSize({ width: 390, height: 844 }); const concept = await conceptTagging(page, 'dark'); const production = await productionTagging(page, 'dark'); expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]); expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']); - expect(production.selectsFit[1]).toBe(concept.selectsFit[1]); + expect(production.selectsFit).toEqual([true, true]); }); /** Runs in the page: the open review dialog: its title, body and the concept's tokens. */ diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 8d29e3f2..fedd3ed6 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -433,10 +433,13 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc } /* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps - under it. */ + under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the + text ("Replace after review"), bounded by its row like the engine select. */ +.setting-row #tag-existing { flex-shrink: 0; /* D132 */ } @media (max-width: 700px) { #workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ } #workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ } + #workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ } } /* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value From 718d4a5cf10fd4fd2f51a0bb24db5698dd40be5b Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 20:01:13 -0700 Subject: [PATCH 2/3] test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. --- tests/browser/full-inspector.spec.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index e4efc5a7..bd530bed 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -676,6 +676,9 @@ test('collection viewer uses real public transforms and resets on navigation',as async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) { await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK(); await page.goto('/prompt_manager/workspace#/images'); + // Install the fake clock before the fixture wraps setTimeout: the wrapped timer (which the viewer session captures) + // must be the fake one, so page.clock.runFor alone decides when the 3500 ms slideshow deadline fires. + await page.clock.install(); await page.evaluate(async preferences=>{ if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences)); const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([ @@ -695,7 +698,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi },preferences); await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png'); - await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000)); + await page.clock.pauseAt(new Date(Date.now()+1000)); } const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/}); const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size); From f0022aca293e5ad7aa4926cd6087578dad95d0d6 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 20:21:00 -0700 Subject: [PATCH 3/3] fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 --- tests/browser/workspace-collections.spec.mjs | 161 +++++++++++++++++++ web/js/workspace/images.mjs | 18 ++- web/js/workspace/index.mjs | 46 ++++-- web/js/workspace/saved-views.mjs | 5 +- 4 files changed, 211 insertions(+), 19 deletions(-) diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs index 49fb3e3b..b87a55b4 100644 --- a/tests/browser/workspace-collections.spec.mjs +++ b/tests/browser/workspace-collections.spec.mjs @@ -330,3 +330,164 @@ test('renaming a collection deleted in another tab says so and shows the deleted await expect(description(page)).toHaveText('This collection no longer exists.'); await expect(rows(page)).toHaveCount(1); }); + +/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */ +async function holdListReads(page, answer = route => route.fallback()) { + const held = []; + let open = false; + await page.route(`**${API}`, route => { + if (route.request().method() !== 'GET') return route.fallback(); + if (open) return answer(route); + held.push(route); + return undefined; + }); + return { + held, + async release() { + open = true; + for (const route of held.splice(0)) await answer(route).catch(() => {}); + }, + }; +} + +test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => { + const manifest = await seed(request); + const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json(); + const [file] = listing.images; + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(heading(page)).toHaveText('Landscapes'); + // Another tab makes "Fresh"; this tab does not list it yet. + const added = await request.post(`${API}/items`, { data: { name: 'Fresh', + items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } }); + await expect(added).toBeOK(); + const fresh = (await added.json()).collection.id; + await expect(rows(page)).toHaveCount(2); + const hold = await holdListReads(page); + // A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read + // supersedes that one while both are held. + await editButton(page).click(); + await nameField(page).fill('Landscape grids'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection renamed.'); + await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`); + await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2); + await expect(main(page)).not.toContainText('Collections could not be loaded.'); + await hold.release(); + await expect(heading(page)).toHaveText('Fresh'); + await expect(imageCards(page)).toHaveCount(1); +}); + +test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + let gone = true; + await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`) + ? route.fulfill({ status: 404, contentType: 'application/json', + body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await expect(rows(page)).toHaveCount(2); + // The shell's re-read is held, then fails: the page must not stay blank. + const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"Collections could not be read."}' })); + await row(page, 'Experiments').click(); + await expect.poll(() => hold.held.length).toBe(1); + await hold.release(); + const retry = main(page).getByRole('button', { name: 'Retry', exact: true }); + await expect(retry).toBeVisible(); + await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.'); + gone = false; + await page.unroute(`**${API}`); + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); + + // The list read again still lists it: the same Retry, never a blank page. + gone = true; + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await row(page, 'Experiments').click(); + await expect(retry).toBeVisible(); + gone = false; + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); +}); + +test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + let missing = null; + await page.route(`**${API}`, async route => { + if (route.request().method() !== 'GET' || missing === null) return route.fallback(); + const response = await route.fetch(); + const data = await response.json(); + data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item); + return route.fulfill({ response, json: data }); + }); + await page.goto(`${workspace}#/collection/${id}`); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); + const rename = async name => { + await editButton(page).click(); + await nameField(page).fill(name); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection renamed.'); + }; + missing = 3; + await rename('Landscapes A'); + await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.'); + missing = 0; + await rename('Landscapes B'); + await expect(missingNote(page)).toHaveCount(0); + missing = 1; + await rename('Landscapes C'); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); +}); + +test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + let release; + const released = new Promise(resolve => { release = resolve; }); + await page.route(`**${API}/${id}`, async route => { + if (route.request().method() !== 'PATCH') return route.fallback(); + await released; + return route.fallback(); + }); + await editButton(page).click(); + await nameField(page).fill('Later'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(editDialog(page)).toHaveCount(0); + await main(page).getByRole('button', { name: 'Save view', exact: true }).click(); + const save = page.getByRole('dialog', { name: 'Save this view', exact: true }); + await expect(save).toBeVisible(); + release(); + await expect(heading(page)).toHaveText('Later'); + await expect(save).toBeVisible(); +}); + +test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999999'; }); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(heading(page)).toBeFocused(); + + await page.route(`**${API}`, route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999998'; }); + await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); + await expect(heading(page)).toBeFocused(); + + await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/saved/5'; }); + await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); + await expect(heading(page)).toBeFocused(); +}); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 4ea59afe..c29e0084 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -466,9 +466,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); }); }); scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); }); - /** The listing refused the open collection: it was deleted (MC6, D140). */ - const collectionGone = next => Boolean(filters.collection) && isCollectionGone(next.error); - let reportedMissing = false; + /** + * The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection + * page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry, + * so a page whose shell cannot confirm the deletion is never left blank. + */ + const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error); + let reportedMissing = null; function continuationText(next) { if (recovering) return 'Images paused until sources and folders are available.'; if (collectionGone(next)) return ''; @@ -511,9 +515,11 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage retry.hidden = !next.error || gone; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading || recovering)); - if (gone && onCollectionMissing) { - if (!reportedMissing) { reportedMissing = true; queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); } - } else if (showsEmpty) { + if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) { + reportedMissing = next.error; + queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); + } + if (showsEmpty) { setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES); empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0); } diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 3670e764..018d181f 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -250,7 +250,9 @@ function mountWorkspace() { if (view.signal.aborted) return; // A view saved since the list loaded (another tab) is read once more before the link counts as gone. if (!savedViews.find(route.savedId) && savedViews.status === 'ready') { - await savedViews.load(); + // settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one. + savedViews.load(); + await savedViews.settled(); if (view.signal.aborted) return; } const saved = savedViews.find(route.savedId); @@ -265,7 +267,8 @@ function mountWorkspace() { window.location.replace('#/images'); return; } - const {description} = renderHeading(main, {title:route.label}); + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); description.textContent = 'Saved views could not be loaded. Your saved data has not changed.'; description.setAttribute('role', 'alert'); const retry = createButton('Retry'); @@ -319,7 +322,9 @@ function mountWorkspace() { await collections.settled(); if (view.signal.aborted) return; if (!collections.find(route.collectionId) && collections.status === 'ready') { - await collections.load(); + // settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one. + collections.load(); + await collections.settled(); if (view.signal.aborted) return; } let collection = collections.find(route.collectionId); @@ -327,12 +332,23 @@ function mountWorkspace() { if (collection) { renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id, // The listing found the collection gone meanwhile: the list is read again and, once it no longer lists - // the collection, the page is shown again (the deleted-collection page). - onCollectionMissing:() => collections.load().then(() => { - if (!collections.find(route.collectionId)) showAgain(); - })}); - const note = missingNote(collection.missing); - if (note) pageHeading(main).description.after(element('p', note, 'small')); + // the collection, the page is shown again (the deleted-collection page). A failed read, or a list that + // still holds it, leaves the listing's own error and Retry (images.mjs). + onCollectionMissing:async () => { + collections.load(); + await collections.settled(); + if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain(); + }}); + // MC3: the note follows every read of the list for the page's life; none while nothing is missing. + const note = element('p', '', 'small'); + const showNote = () => { + const text = missingNote(collections.find(route.collectionId)?.missing); + note.textContent = text; + if (!text) note.remove(); + else if (!note.isConnected) pageHeading(main)?.description.after(note); + }; + showNote(); + view.cleanup(collections.subscribe(showNote)); // The dialog's Delete question uses the latest stored count. mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger, collection: collection = {...collection, ...collections.find(collection.id), name:collection.name}, @@ -349,7 +365,8 @@ function mountWorkspace() { onGone: () => collections.settled().then(showAgain) })); return; } - const {description} = renderHeading(main, {title:route.label}); + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); document.title = `${routeTitle(route)} · Prompt Manager`; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (collections.status === 'ready') { @@ -370,13 +387,18 @@ function mountWorkspace() { main.append(retry); } + /** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */ + function focusTitle(h1) { + if (!firstRender) h1.focus(); + firstRender = false; + } + function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) { document.title = `${routeTitle(route)} · Prompt Manager`; // The concept's heading(); each view fills its description and actions. // The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`. const { description, h1 } = renderHeading(main, { title: route.label }); - if (!firstRender) h1.focus(); - firstRender = false; + focusTitle(h1); document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index 885d30d0..7f027860 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -265,15 +265,18 @@ export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete // Kept open: one rename at a time, its outcome shown in place. say(''); for (const button of [remove, cancel, save]) button.disabled = true; + // Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone. + const stillOpen = () => input.isConnected; try { await onRename(name); if (scope.signal.aborted) return; - closeWorkspaceDialog(); + if (stillOpen()) closeWorkspaceDialog(); showWorkspaceToast(nouns.renamed); } catch (error) { if (scope.signal.aborted) return; for (const button of [remove, cancel, save]) button.disabled = false; const outcome = renameError(error) ?? { toast: nouns.renameFailed }; + if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; } if (outcome.field) { say(outcome.field); input.focus(); return; } if (outcome.close) closeWorkspaceDialog(); showWorkspaceToast(outcome.toast);