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.
This commit is contained in:
Vito Sansevero
2026-10-27 20:22:05 -07:00
parent cffbe6ee46
commit 7ed45578b0
4 changed files with 24 additions and 13 deletions
+6 -4
View File
@@ -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).
+2
View File
@@ -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 }) => {
+11 -6
View File
@@ -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 });
+5 -3
View File
@@ -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';