From 7ed45578b039b3600d5f942c004137c449db91b2 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 20:22:05 -0700 Subject: [PATCH] fix(images): focus after a changed source; Browse is a fresh Images visit (#332) Review fixes: a 409 source-changed reload moves focus to the heading as a success does; a selection the listing no longer holds says none could be found instead of sending nothing; a Tags & categories Browse opens a plain Images visit filtered by the category (remembered and with the Appearance defaults) rather than a saved-view visit. --- TESTING.md | 10 ++++++---- tests/browser/workspace-images.spec.mjs | 2 ++ web/js/workspace/images-selection.mjs | 17 +++++++++++------ web/js/workspace/images.mjs | 8 +++++--- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/TESTING.md b/TESTING.md index 5f486c1c..fb36ddca 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1266,7 +1266,8 @@ Recorded deviations on this page (spec §8): it finish without a toast, and the return loads the collection afresh instead of the saved visit. Its success reloads the categories and the listing together (clearing the selection, focus to the heading, and, as any replaced collection does, the - inspector moves to the first listed image); failure keeps the selection. `images selection bar matches + inspector moves to the first listed image); a failure keeps the selection, except a + changed source (409), which reloads the listing as a success does. `images selection bar matches the concept` compares the bar and the checked card with one card checked; the omitted actions flow again from the concept's own boxes, checked against the concept page with them removed at runtime. @@ -1475,8 +1476,8 @@ Recorded deviations on this page (spec §8): - **D146** (#332, amends D21 and D39): the category chip names its origin in visible text. It is `span.chip` > `span.ca-visually-hidden` (the D21 prefix) + the category + `span.chip-source` (" · this image", " · prompt" or " · recipe"; muted - `--ca-muted`, `white-space: nowrap`, so at 390px the chip may wrap only before the - origin). It shows when the inspection names a non-empty category and either the + `--ca-muted`, `white-space: nowrap`, so at 390px the origin never splits; the chip may wrap + before it or inside a long category). It shows when the inspection names a non-empty category and either the scope is `image` (any link status, so an unlinked or ambiguous file with an own category shows it) or the file is linked. The concept's chip shows the bare name (app.js a402). The inspector comparison (inspector-concept.spec) measures each chip's @@ -5670,7 +5671,8 @@ fixture-only tags after the concept's sample tags. (`image_count`, the stored rows, missing files included): "N positive prompts · M images" beside prompts, "M images" for an image-only value, which follows every prompt-backed row (by value) before Manage category. Browse on an image-only row opens - Images with exactly that Category filter (its Library would be empty); every other + Images with exactly that Category filter (its Library would be empty), as a fresh + Images visit that becomes the Images return state, the way Browse seeds the Library's; every other row opens the Library as before. Manage category's select lists image-only values last, so past 1,000 categories they are the first left out (the dialog says the list is capped). diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 91f9d250..5c2184b4 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -2252,6 +2252,8 @@ test('a changed source says so and reloads the listing (#332)', async ({ page }) await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.'); await reloaded; await expect(cards(page)).toHaveCount(2); + // The reload hides the bar with Set category in it: focus moves to the page heading, as after a success. + await expect(page.locator('#workspace-main h1')).toBeFocused(); }); test('a recipe-linked image is listed under its recipe\'s category (#332 D142)', async ({ page, request }) => { diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index c930f916..dccf8bc4 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -3,7 +3,7 @@ import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs'; import { inspectPath, toInspectionRecord } from './inspection-state.mjs'; import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs'; import { CATEGORY_DIALOG, CATEGORY_MAX_LENGTH, CATEGORY_URL, MAX_CATEGORY_IMAGES, REMOVE_DIALOG, USE_PROMPT_CATEGORY, - clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs'; + NONE_FOUND, clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs'; import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs'; import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; @@ -270,7 +270,9 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o if (categorizing) return; const count = allMatching ? getListing().total : checked.size; const targets = allMatching ? {filter: getFilter()} : {items: bulkTargets(checkedImages())}; - if (count > MAX_CATEGORY_IMAGES || (!allMatching && !targets.items)) { showWorkspaceToast(TOO_MANY); return; } + if (count > MAX_CATEGORY_IMAGES) { showWorkspaceToast(TOO_MANY); return; } + // Checked files the listing no longer holds (a refresh dropped them): nothing to send. + if (!allMatching && !targets.items?.length) { showWorkspaceToast(NONE_FOUND); return; } openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label, action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(), secondary: { label: USE_PROMPT_CATEGORY, onClick: () => confirmRemove(button, targets, count) }, @@ -293,8 +295,8 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o } /** * The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page - * not to save a visit that predates it). A failure keeps the selection (a changed source reloads the listing); a - * success reloads through `onCategorySet`. + * not to save a visit that predates it). A failure keeps the selection, except a changed source, which reloads the + * listing as a success does (`onCategorySet`). */ async function writeCategory(button, send, outcome) { categorizing = true; @@ -309,13 +311,16 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o release(); } if (scope.signal.aborted) return; + const focused = document.activeElement === button; if (failure !== null || !result) { showWorkspaceToast(failureMessage(failure)); - if (failure?.code === 'source-changed') onCategorySet(); + // A changed source reloads the listing (clearing the selection); any other failure keeps it. + if (failure?.code !== 'source-changed') return; + onCategorySet(); + if (focused) focusFallback.focus({ preventScroll: true }); return; } showWorkspaceToast(outcome(result)); - const focused = document.activeElement === button; onCategorySet(); // The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading. if (focused) focusFallback.focus({ preventScroll: true }); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 57ea4091..bfa9348e 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -125,11 +125,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next))); scope.cleanup(() => inspection.dispose()); scope.cleanup(() => detailInspector?.dispose()); - // A Tags & categories Browse (browseImages) is handed to this mount once, as a saved view's state is. + // A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its + // category, which becomes the Images return state like any plain visit (as Browse does for the Library). const handed = !embedded && !favorites && !savedState ? browseState : null; if (!embedded && !favorites) browseState = null; - const saved = embedded || !(savedState || handed) ? null : imagesFilters(savedState || handed); - const prior = embedded || saved || favorites ? null : lastVisit; + const saved = embedded || !savedState ? null : imagesFilters(savedState); + const prior = embedded || saved || favorites || handed ? null : lastVisit; // A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6). const visit = prior?.stale ? null : prior; // The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no @@ -148,6 +149,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved, + ...(handed ? {category: handed.category} : {}), favorites: favorites || saved?.favorites === true }; filters.tags = [...filters.tags]; if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';