Compare commits

...
Author SHA1 Message Date
Vito Sansevero 239423e0c8 test(images): set the bar's observer stub outside the fake document (#531) 2026-10-30 13:50:42 -07:00
Vito Sansevero 99e392436e fix(images): at one column Info goes to the panel and loading waits for the next move (#531)
Opening the viewer on Info blocked the inspector and zoom contracts, and
popped up on a zoomed desktop.
2026-10-30 13:41:48 -07:00
Vito Sansevero 231f8a1c75 docs(testing): move the sticky chrome section beside the workspace contracts (#531) 2026-10-30 12:25:59 -07:00
Vito Sansevero c4a99d9915 fix(workspace): mark the sticky chrome's concept overlays and stub the observer in the bar's unit test (#531) 2026-10-30 12:13:10 -07:00
Vito Sansevero 6c168bf381 feat(workspace): selection bars stay in view, Back to top, side panels stay with you (#531)
The selection bars stick to the top without moving the cards under the
pointer, and a finished bulk action shows the refreshed list from the top.
A Back to top chip follows every page, above the toast's spot. The Needs
review panel is sticky; at one column Info brings the details into view.
Recorded as D186-D189.
2026-10-30 11:30:24 -07:00
16 changed files with 535 additions and 18 deletions
+17
View File
@@ -5617,6 +5617,23 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
--project chromium --project touch --project firefox --project webkit
```
## Sticky chrome (#531)
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
- **D189:** where a panel is one column (static): Info brings the panel into view. On
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
## Workspace data fields for the concept pages (#249)
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
+4 -1
View File
@@ -474,7 +474,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected',
'Set category','Add to collection','Export metadata','Delete…','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// D186: the panel's sticky offset clears the shown bar; at one column the bar is one sideways row (its items are checked above).
const stuckPanel={...oracle,panel:{...oracle.panel,...(width<=700?{top:actual.panel.top}:{}),box:{...oracle.panel.box,top:actual.panel.box.top}}};
expect(parseFloat(actual.panel.box.top)).toBeGreaterThanOrEqual(20);
expect.soft(compare(stuckPanel,actual,manifest,width<=700?['panel']:['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
await mutate(page,{css:'.bulkbar { padding: 4px }'});
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
+3 -1
View File
@@ -374,7 +374,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
expect(actual).toEqual(reference);
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
expect(actual).toEqual(mapped);
// The comparison notices a moved checkbox (spec §7 mutation check).
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
@@ -231,6 +231,9 @@ export function mapConceptReview(reference,actual) {
const actualActions=findNode(actual.main,'div.dialog-actions');
let cardIndex=0;
let main={...reference.main,id:actual.main.id};
// D188: the Resolve panel is sticky.
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
let noticeLoss=0;
main=editNodes(main,'div.notice',notice=>{
const chip=notice.children.find(child=>child.tag==='span.chip');
@@ -0,0 +1,321 @@
// #531 (D186-D189): the selection bars stay in view, a Back to top chip follows the page, and the side panels stay with
// you. Beyond the concept; the concept itself is unchanged.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const IMAGES = '/prompt_manager/workspace#/images';
const LIBRARY = '/prompt_manager/workspace#/library';
const REVIEW = '/prompt_manager/workspace#/review';
const PHONE = { width: 390, height: 844 };
const imageCards = page => page.locator('#image-grid article[data-image-key]');
const promptCards = page => page.locator('article[data-prompt-id]');
const imageBar = page => page.locator('#bulkbar > .bulkbar');
const libraryBar = page => page.locator('#library-bulkbar > .bulkbar');
const chip = page => page.getByRole('button', { name: 'Back to top', exact: true });
const toast = page => page.locator('#workspace-toast');
const reviewPanel = page => page.getByRole('region', { name: 'Resolve the association', exact: true });
const reviewCards = page => page.locator('.review-queue article.media-card');
const box = locator => locator.evaluate(node => {
const { top, bottom, left, right } = node.getBoundingClientRect();
return { top, bottom, left, right };
});
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
/** Scroll the window to `y` and wait for the frame that applied it. */
async function scrollToY(page, y) {
await page.evaluate(target => new Promise(resolve => {
scrollTo({ top: target, behavior: 'instant' });
requestAnimationFrame(() => requestAnimationFrame(resolve));
}), y);
}
/** The first card wholly below `below` px, by its index in the list. */
async function cardBelow(cards, below) {
const count = await cards.count();
for (let index = 0; index < count; index++) {
const card = cards.nth(index);
if ((await box(card)).top <= below) continue;
// By its key, not its position: the mounted window moves as the page does.
const key = await card.evaluate(node => ['imageKey', 'promptId', 'reviewId'].map(name => [name, node.dataset[name]]).find(([, value]) => value));
const attribute = key[0].replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
return cards.page().locator(`article[data-${attribute}=${JSON.stringify(key[1])}]`);
}
throw new Error(`no card below ${below}`);
}
async function openImages(page, request, extra = 100) {
await expect(await request.post('/__test__/reset', { data: { extra_images: extra } })).toBeOK();
await page.goto(IMAGES);
await expect(imageCards(page).first()).toBeVisible();
}
async function openLibrary(page, request, extra = 40) {
await expect(await request.post('/__test__/reset', { data: { native_library: true, extra_prompts: extra } })).toBeOK();
await page.goto(LIBRARY);
await expect(promptCards(page).first()).toBeVisible();
}
async function openReview(page, request, items = 30) {
await expect(await request.post('/__test__/review/reset', { data: { items } })).toBeOK();
await page.goto(REVIEW);
await expect(reviewPanel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
}
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
test(`Images: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
const card = await cardBelow(imageCards(page), 300);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
// Keep scrolling and selecting: the bar stays; Clear returns the card to its place too.
await scrollToY(page, 1900);
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
const later = await cardBelow(imageCards(page), 300);
await later.locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page).locator('strong')).toHaveText('2 selected');
const held = (await box(later)).top;
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(imageBar(page)).toHaveCount(0);
expect(Math.abs((await box(later)).top - held)).toBeLessThanOrEqual(1);
});
test(`Library: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
if (viewport) await page.setViewportSize(viewport);
await openLibrary(page, request);
await scrollToY(page, 2000);
await expect(promptCards(page).first()).toBeAttached();
const card = await cardBelow(promptCards(page), 250);
const before = (await box(card)).top;
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page).locator('strong')).toHaveText('1 selected');
expect(inViewport(await box(libraryBar(page)), page)).toBe(true);
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
expect(await card.isVisible()).toBe(true);
});
}
test('the Images panel clears the stuck bar and a focused card is not left under it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 300)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
await expect.poll(async () => (await box(page.locator('#image-inspector'))).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
expect(overlaps(await box(page.locator('#image-inspector')), await box(imageBar(page)))).toBe(false);
// A control scrolled to by focus lands below the bar; the bar's own controls do not move the page.
const y = await page.evaluate(() => scrollY);
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
expect(await page.evaluate(() => scrollY)).toBe(y);
const above = imageCards(page).first().locator('.image-overlays input[type="checkbox"]');
await above.focus();
expect((await box(above)).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
});
test('Back to top shows off the top of a page, goes to the top and hands focus to the page', async ({ page, request }) => {
await openImages(page, request);
await expect(chip(page)).toBeHidden();
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
const loaded = await page.evaluate(() => document.querySelector('#result-count').textContent);
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect(chip(page)).toBeHidden();
await expect(page.locator('#workspace-main')).toBeFocused();
expect(await page.evaluate(() => document.querySelector('#result-count').textContent)).toBe(loaded);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Back to top on the virtualized Library lands on the first card and stays there', async ({ page, request }) => {
await openLibrary(page, request, 200);
// Past 120 loaded prompts the shelf mounts only a window of them (each pass reaches the next page's trigger).
await expect.poll(async () => {
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }));
return page.locator('article[data-index="0"]').count();
}, { timeout: 30000 }).toBe(0);
await expect(chip(page)).toBeVisible();
await chip(page).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await expect.poll(async () => Number(await promptCards(page).first().getAttribute('data-index'))).toBe(0);
await expect(promptCards(page).first()).toBeInViewport();
expect(await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(scrollY)))))).toBe(0);
});
test('Back to top jumps, under reduced motion and otherwise', async ({ page, request }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await openImages(page, request, 20);
await scrollToY(page, 600);
await expect(chip(page)).toBeVisible();
const after = await chip(page).evaluate(node => { node.click(); return scrollY; });
expect(after).toBe(0);
});
for (const viewport of [{ width: 1280, height: 720 }, PHONE]) {
test(`Back to top moves clear of a toast and of the bar at ${viewport.width}px`, async ({ page, request }) => {
await page.setViewportSize(viewport);
await openImages(page, request);
await scrollToY(page, 1500);
await expect(chip(page)).toBeVisible();
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect(overlaps(await box(chip(page)), await box(imageBar(page)))).toBe(false);
const alone = await box(chip(page));
await page.evaluate(() => {
document.getElementById('workspace-toast').textContent = 'A much longer message than most, one that keeps going, so that it wraps to more than two lines on the narrowest screen a phone will give this page to show it on, and then some more words again.';
});
await expect(toast(page)).not.toBeEmpty();
await expect.poll(async () => overlaps(await box(chip(page)), await box(toast(page)))).toBe(false);
expect((await box(chip(page))).bottom).toBeLessThanOrEqual((await box(toast(page))).top);
await expect(chip(page)).toBeInViewport();
await page.evaluate(() => { document.getElementById('workspace-toast').textContent = ''; });
await expect.poll(async () => Math.round((await box(chip(page))).bottom)).toBe(Math.round(alone.bottom));
});
}
test('a stuck bar at 390px is one row that keeps every action reachable', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
expect((await box(imageBar(page))).bottom - (await box(imageBar(page))).top).toBeLessThan(80);
const clear = imageBar(page).getByRole('button', { name: 'Clear', exact: true });
await clear.scrollIntoViewIfNeeded();
await expect(clear).toBeInViewport();
await clear.click();
await expect(imageBar(page)).toHaveCount(0);
});
test('the Needs review panel stays with you, and its long content scrolls inside it', async ({ page, request }) => {
await page.setViewportSize({ width: 1400, height: 500 });
await openReview(page, request);
await scrollToY(page, 700);
await expect(reviewPanel(page)).toBeInViewport();
const rect = await box(reviewPanel(page));
expect(rect.top).toBeGreaterThanOrEqual(0);
expect(rect.bottom).toBeLessThanOrEqual(500);
const card = await cardBelow(reviewCards(page), 100);
await card.getByRole('button', { name: /^Inspect / }).click();
const name = await card.locator('.media-info').first().textContent();
expect(name).toBeTruthy();
await expect(reviewPanel(page)).toBeInViewport();
expect((await box(reviewPanel(page))).top).toBe(rect.top);
});
test('at 390px Info on a lower review card brings the panel into view', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openReview(page, request);
await expect(reviewPanel(page)).not.toBeInViewport();
const card = reviewCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(reviewPanel(page)).toBeInViewport();
});
// D189: at one column the Images panel follows the whole list: Info goes there, and a batch doesn't push it away.
test('at 390px Info on an image brings its panel into view and loading waits for the next move', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request);
expect(await page.evaluate(() => scrollY)).toBe(0);
await expect(page.locator('#image-inspector')).not.toBeInViewport();
let reads = 0;
page.on('request', item => { if (new URL(item.url()).pathname.endsWith('/images/recent')) reads++; });
const card = imageCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(page.locator('#image-inspector')).toBeInViewport();
await expect(page.locator('#viewer-dialog')).toBeHidden();
const held = (await box(page.locator('#image-inspector'))).top;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
expect((await box(page.locator('#image-inspector'))).top).toBe(held);
expect(reads).toBe(0);
// The next move releases it: loading resumes.
const next = page.waitForRequest(item => new URL(item.url()).pathname.includes('/images'));
await page.mouse.wheel(0, 40);
await next;
});
// Review's review-state load more is a button, so Library's anchor stays honest through a compensated bar.
test('a Library selection does not jump after a pending height correction', async ({ page, request }) => {
await page.setViewportSize({ width: 1000, height: 800 });
await openLibrary(page, request);
await scrollToY(page, 3000);
const card = await cardBelow(promptCards(page), 250);
await card.locator('.image-overlays input[type="checkbox"]').check();
await expect(libraryBar(page)).toBeVisible();
const before = (await box(card)).top;
await page.setViewportSize({ width: 1300, height: 800 });
await expect(card).toBeAttached();
await page.setViewportSize({ width: 1000, height: 800 });
await expect.poll(async () => Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
});
// #531: after a bulk action on a bar, the page shows the refreshed list from the top.
async function selectFarDownImages(page, request) {
await openImages(page, request);
await scrollToY(page, 1500);
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
await expect(imageBar(page)).toBeVisible();
}
const atTop = page => expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
const applyDialog = async (page, title, value) => {
const dialog = page.getByRole('dialog', { name: title, exact: true });
await dialog.locator('#action-input').fill(value);
await dialog.getByRole('button', { name: /^(Apply|Add)/ }).click();
await expect(dialog).toHaveCount(0);
};
test('Images: Set category returns to the top of the refreshed list', async ({ page, request }) => {
await selectFarDownImages(page, request);
const read = page.waitForRequest(item => item.url().includes('/prompt_manager/images/'));
await imageBar(page).getByRole('button', { name: 'Set category', exact: true }).click();
await applyDialog(page, 'Categorize selected images', 'Studio');
await read;
await atTop(page);
await expect(imageBar(page)).toHaveCount(0);
await expect(imageCards(page).first()).toBeInViewport();
});
test('Images: Add tags returns to the top of the refreshed list when its job ends', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
await applyDialog(page, 'Tag selected images', 'sticky-tag');
await atTop(page);
await expect(imageBar(page)).toBeVisible();
});
test('Library: Set category and Add tags return to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
for (const [button, title, value] of [['Set category', 'Categorize selected prompts', 'Studio'], ['Add tags', 'Tag selected prompts', 'sticky']]) {
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: button, exact: true }).click();
await applyDialog(page, title, value);
await atTop(page);
await expect(libraryBar(page)).toHaveCount(0);
await expect(promptCards(page).first()).toBeInViewport();
}
});
test('Images: Delete… returns to the top of the reloaded list', async ({ page, request }) => {
await selectFarDownImages(page, request);
await imageBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
const dialog = page.locator('dialog[open]');
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
await dialog.locator('.dialog-actions button.danger').click();
await expect(toast(page)).toContainText('Removed 1 image from the library.');
await atTop(page);
await expect(page.locator('#bulkbar')).toBeEmpty();
});
test('Library: Delete… returns to the top of the reloaded shelf', async ({ page, request }) => {
await openLibrary(page, request);
await scrollToY(page, 2000);
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
await libraryBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
await page.locator('dialog[open] .dialog-actions button.danger').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await atTop(page);
await expect(page.locator('#library-bulkbar')).toBeEmpty();
});
@@ -75,6 +75,9 @@ function fakeDOM() {
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
function mountBar(dom, items, checkedKeys, options = {}) {
// The bar publishes its height for the sticky chrome (D186); nothing in this DOM ever resizes.
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} };
document.documentElement ??= { style: { setProperty() {}, removeProperty() {} } };
const scope = createScope();
const grid = new dom.Element('div');
const focusFallback = new dom.Element('h1');
+6
View File
@@ -700,5 +700,11 @@
"branches": 95,
"functions": 98,
"statements": 97
},
"workspace/sticky-chrome.mjs": {
"lines": 80,
"branches": 60,
"functions": 80,
"statements": 80
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ export function collectionTargets(images, { allMatching = false, total = images.
* the dialog seam. A stale source (409) calls `onSourceChanged()` after its toast, so the page reloads the listing.
*/
export function openAddToCollection({ scope, collections, images, trigger = null, allMatching = false, total = images.length,
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null }) {
notify = showWorkspaceToast, openInput = openInputDialog, onSourceChanged = null, onAdded = null }) {
const { targets, refusal } = collectionTargets(images, { allMatching, total });
if (refusal) { notify(refusal); return; }
openInput({ scope, trigger, title: ADD_DIALOG_TITLE, label: ADD_DIALOG_LABEL, action: ADD_DIALOG_ACTION,
@@ -45,7 +45,7 @@ export function openAddToCollection({ scope, collections, images, trigger = null
if (name === null) { notify(ADD_FAILED_TOAST); return; }
try {
const result = await collections.add({ name }, targets);
if (!scope.signal.aborted) notify(addedMessage(result));
if (!scope.signal.aborted) { notify(addedMessage(result)); onAdded?.(); }
} catch (error) {
if (scope.signal.aborted) return;
notify(writeFailure(error, ADD_FAILED_TOAST));
+6 -3
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
@@ -51,7 +52,7 @@ function el(tag, text, className) {
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null, removedView = null,
collection = null, onCollectionsChanged = null, onSourceChanged = null, onCollectionAdded = null, onDeleted = null, removedView = null,
onReturn = null }) {
let checked = initial;
let exporting = false, categorizing = false;
@@ -59,6 +60,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
let anchor = null, allMatching = false, touchMode = false, lastPointer = 'mouse', shiftBox = null;
const element = el('div');
element.id = 'bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const selectMatching = createButton('');
scope.listen(selectMatching, 'click', () => {
@@ -87,7 +89,7 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
if (collections) {
const addToCollection = createButton('Add to collection');
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
trigger: addToCollection, ...collectionSelection(), onSourceChanged, onAdded: onCollectionAdded }));
bulk.append(addToCollection);
}
if (collections && collection) {
@@ -124,7 +126,8 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
}
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
function render() { keepingPlace(element, paintBar); }
function paintBar() {
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
paintMode();
const listing = getListing();
+32 -7
View File
@@ -5,6 +5,7 @@ import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './imag
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
import { mountSelectionBar } from './images-selection.mjs';
import { scrollToPageTop } from './sticky-chrome.mjs';
import { createPromptSender } from './prompt-sender.mjs';
import { promptRoles } from './prompt-blocks.mjs';
import { createWorkspaceIcon } from './icons.mjs';
@@ -301,6 +302,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
collection.append(continuation);
// After a Load more or Retry press, the index of the first image that batch appends (concept: focus it).
let focusAfterLoad = null;
let panelHold = false;
// The concept's side panel (app.js a426, D20); the Library-embedded gallery has its own page around it.
const details = el('aside', undefined, 'detail-panel');
if (!embedded) details.id = 'image-inspector';
@@ -310,11 +312,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
onDeleted:deleted,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); scrollToPageTop(); }, activity,
// #531: a finished bulk action shows its result from the top. Tagging and collections keep the selection (D85, D138).
onTagged:keys => { refreshTagged(keys); bulkRefreshed(); },
onDeleted:() => { deleted(); scrollToPageTop(); },
// #498: the removed view's bar holds only Return to library and Clear.
removedView:() => filters.removed,
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger}),
onReturn:(images, extra, trigger) => returnSome({images, ...extra, trigger, onDone:scrollToPageTop}),
// The Set category field suggests the toolbar's categories (#332 MC13).
getCategories:() => categoryNames,
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
@@ -325,7 +329,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
return filter.root !== undefined && typeof scope === 'string' ? {...filter, root_scope:scope} : filter;
},
// #331 D138: Add to collection, and Remove from collection on an open collection, which then lists it again.
collections, collection:filters.collection || null, onCollectionsChanged:collectionChanged,
collections, collection:filters.collection || null, onCollectionsChanged:() => { collectionChanged(); scrollToPageTop(); }, onCollectionAdded:bulkRefreshed,
onSourceChanged:refreshInspectionCollection});
if (bar) main.append(bar.element);
main.append(body);
@@ -402,7 +406,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
if (event.target.closest('.star-button')) toggleFavorite(api, image);
else if (event.target.closest('.image-open')) previewImage(image);
else if (action && !openCardTagging({api, scope, image, action, embedded, onSourceChanged:recovery.recover,
onRefresh:refreshInspectionCollection})) inspect(image);
onRefresh:refreshInspectionCollection})) {
inspect(image);
bringPanelIntoView();
}
});
function renderVisible() {
@@ -637,7 +644,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
scope.listen(more, 'click', pressLoad);
scope.listen(retry, 'click', () => (state?.error?.code === 'cursor-stale' ? refreshInspectionCollection() : pressLoad()));
/**
* D189: at one column the panel follows the whole list, so Info goes to it. That passes the continuation, so loading
* waits for the reader's next move: a batch appended now would push the panel away.
*/
function bringPanelIntoView() {
if (getComputedStyle(details).position !== 'static') return;
panelHold = true;
details.scrollIntoView({ block: 'start' });
}
const releasePanel = () => { if (panelHold) { panelHold = false; scheduleLoad(); } };
for (const name of ['wheel', 'touchmove', 'keydown']) scope.listen(window, name, releasePanel, { passive: true });
function maybeLoad() {
if (panelHold) return;
if (!observer || recovering || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('#viewer-dialog[open]')) return;
const bounds = continuation.getBoundingClientRect();
if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore();
@@ -702,8 +721,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
else if (!showRemoved.isConnected) heading.actions.prepend(showRemoved);
}
/** #498: Return to library for the checked images, "all N matching" (`filter`) or the inspector's one. */
function returnSome({images = [], filter = null, count = images.length, trigger = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) reset(); }});
function returnSome({images = [], filter = null, count = images.length, trigger = null, onDone = null}) {
return returnImages({api, scope, store:activity, images, filter, count, trigger, onReturned:() => { if (!scope.signal.aborted) { reset(); onDone?.(); } }});
}
/** #480: Delete… for one file, from the inspector or the viewer (`after` closes the viewer first). */
function deleteOne(target, trigger, after = null) {
@@ -819,6 +838,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
* is read again in place, keeping the scroll and the checked files still listed. A failed re-read lists the page
* again from its start, whose failure shows the usual Retry (or the deleted-collection page).
*/
/** #531: after a bulk action that keeps the selection: the listing re-read, shown from the top. */
function bulkRefreshed() {
if (scope.signal.aborted) return;
scrollToPageTop();
if (!recovering) stream.refresh().catch(() => { if (!scope.signal.aborted) reset(); });
}
async function collectionChanged() {
if (scope.signal.aborted || !filters.collection || recovering) return;
try {
+2
View File
@@ -4,6 +4,7 @@ import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigat
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS,
JOB_END_COUNTS } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountBackToTop } from './sticky-chrome.mjs';
import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs';
import { forgetLibraryVisit, mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
import { mountVocabulary } from './vocabulary.mjs';
@@ -83,6 +84,7 @@ function mountWorkspace() {
// row's count and the Activity page, on every route.
const activity = createActivityStore({ api, scope, connect: url => new EventSource(resolveUrl(url)) });
mountActivityTopbar({ store: activity, scope });
mountBackToTop({ scope, toast: document.getElementById('workspace-toast') });
activity.load();
// D129: a node-folder library is offered a move (or "Keep it here") above every page.
mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope });
+7 -1
View File
@@ -1,3 +1,4 @@
import { keepingPlace, publishBarHeight } from './sticky-chrome.mjs';
import { createButton } from '../shared/toolkit/core.js';
import { selectionLabel, toggleSelection } from './images-state.mjs';
import { applyKeys, rangeKeys } from './images-multiselect.mjs';
@@ -129,10 +130,11 @@ export function categoryProblem(value) {
* to `focusFallback` when it was on the bar the reload hides.
*/
export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, getCategories, focusFallback, onCommitted,
onReload, getShelf = () => 'active', nameOf = () => null }) {
onReload, getShelf = () => 'active', nameOf = () => null, onPlaceKept = () => {} }) {
let checked = Object.freeze(new Set()), anchor = null, shiftBox = null, writing = false;
const element = el('div');
element.id = 'library-bulkbar';
publishBarHeight(scope, element);
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
const addTags = createButton('Add tags');
const setCategory = createButton('Set category');
@@ -164,6 +166,10 @@ export function mountLibrarySelection({ scope, api, grid, getItems, getEpoch, ge
const ids = () => getItems().map(item => item.id);
function render() {
const shift = keepingPlace(element, paintBar);
if (shift) onPlaceKept(shift);
}
function paintBar() {
if (!checked.size) { bulk.remove(); return; }
const shelf = getShelf() === 'archived' ? 'archived' : 'active';
if (shelf !== shownShelf) {
+4 -2
View File
@@ -16,6 +16,7 @@ import {mountToolbar} from './toolbar.mjs';
import {libraryFilters,libraryViewState} from './saved-views.mjs';
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
import {mountLibrarySelection} from './library-selection.mjs';
import {scrollToPageTop} from './sticky-chrome.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';
@@ -190,10 +191,11 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
const grid=el('div',undefined,'library-grid');grid.setAttribute('role','list');grid.setAttribute('aria-label','Positive prompts');
// #437 D148: select prompts and set their category (the legacy admin's Set Category); a write reloads the shelf.
let categoryNames=[];
const selection=mountLibrarySelection({scope,api,grid,getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
// #531: the bar's compensating scroll is not the reader moving, so a pending anchor keeps its offset.
const selection=mountLibrarySelection({scope,api,grid,onPlaceKept:shift=>{observedScrollY+=shift;},getItems:()=>state?.items ?? [],getEpoch:()=>state?.epoch ?? null,
getCategories:()=>categoryNames,focusFallback:{focus:()=>focusHeading(main)},getShelf:()=>filters.shelf,
nameOf:id=>{const item=state?.items.find(entry=>entry.id===id);return item?displayTitle(item):null;},
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();}});
onCommitted:(kind,receipt)=>invalidateLibraryAfterWrite({kind:`bulk-${kind}`,receipt}),onReload:()=>{loadCategories();reset();scrollToPageTop();}});
const message=el('p','','library-status');message.setAttribute('role','status');
const continuation=el('div',undefined,'library-continuation');
const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true;
+2
View File
@@ -288,6 +288,8 @@ export function mountReview(main, { api, scope, onDecision = () => {}, onTotal,
}
// Info selects the item, as the concept's `inspect` does on this page (app.js a1244–1248).
controller.select(item.id);
// D189: at one column the panel cannot stay beside the queue, so Info brings it into view.
if (getComputedStyle(panel).position === 'static') panel.scrollIntoView({ block: 'nearest' });
});
let shownTotal = null;
+91
View File
@@ -0,0 +1,91 @@
/**
* The chrome that stays with you as a page scrolls (#531, beyond the concept; TESTING.md D186 and D187).
*
* - `keepingPlace`: repaint a sticky selection bar without moving the cards under the pointer.
* - `publishBarHeight`: tell the root how much of the viewport's top a shown bar takes, for the Images panel's sticky
* offset and for `scroll-margin` (a focused card is not left under the bar).
* - `mountBackToTop`: the shell's chip, which rests above the toast's spot and rises only for a taller toast.
*/
/** How far off the top of the page the chip appears, in CSS pixels. */
export const BACK_TO_TOP_AFTER = 200;
/** The gap between the stuck bar and whatever it clears (its `inset-block-start` above, and as much below). */
const BAR_GAP = 16;
const TOAST_GAP = 8;
/**
* Run `paint`, which may change the height of `element`. While the element's place in the page's flow is above the
* viewport (the bar is stuck), scroll by the difference, so the content below stays where it was. On screen, the bar
* pushes the content as the concept's does. Native scroll anchoring would add its own correction in some browsers, so
* it is off until the next frame. Returns the distance scrolled.
*/
export function keepingPlace(element, paint) {
const next = element.nextElementSibling;
if (!next || !element.isConnected) { paint(); return 0; }
const before = next.getBoundingClientRect().top;
const root = document.documentElement;
const anchoring = root.style.overflowAnchor;
root.style.overflowAnchor = 'none';
paint();
const shift = next.getBoundingClientRect().top - before;
requestAnimationFrame(() => { root.style.overflowAnchor = anchoring; });
if (!shift || before >= Math.max(0, element.getBoundingClientRect().bottom)) return 0;
const from = window.scrollY;
window.scrollBy({ top: shift, behavior: 'instant' });
return window.scrollY - from;
}
/** Publish the height of the bar inside `element` as `--selection-bar-block` while it shows; cleared with `scope`. */
export function publishBarHeight(scope, element) {
const root = document.documentElement;
const clear = () => root.style.removeProperty('--selection-bar-block');
const update = () => {
const height = element.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--selection-bar-block', `${height + BAR_GAP}px`);
else clear();
};
const observer = new ResizeObserver(update);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); clear(); });
}
/** Go to the top of the page at once: the pages' own height corrections would cancel a smooth scroll part way. */
export function scrollToPageTop() {
window.scrollTo({ top: 0, behavior: 'instant' });
}
/** One chip for the whole shell: shown off the top of any page; pressing it goes to the top at once. */
export function mountBackToTop({ scope, toast }) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'back-to-top';
button.hidden = true;
const arrow = document.createElement('span');
arrow.setAttribute('aria-hidden', 'true');
arrow.textContent = '↑';
button.append(arrow, ' Back to top');
toast.after(button);
// Scroll events already come once a frame, and this only reads `scrollY`.
const update = () => { button.hidden = window.scrollY <= BACK_TO_TOP_AFTER; };
scope.listen(window, 'scroll', update, { passive: true });
scope.listen(button, 'click', () => {
// Never animated, so reduced motion holds. The chip hides under the focus, so focus moves to the page.
scrollToPageTop();
document.getElementById('workspace-main')?.focus({ preventScroll: true });
update();
});
const root = document.documentElement;
const followToast = () => {
const height = toast.getBoundingClientRect().height;
if (height > 0) root.style.setProperty('--toast-block', `${height + TOAST_GAP}px`);
else root.style.removeProperty('--toast-block');
};
const toastObserver = new MutationObserver(followToast);
toastObserver.observe(toast, { childList: true, characterData: true, subtree: true });
scope.listen(window, 'resize', followToast);
scope.cleanup(() => { toastObserver.disconnect(); root.style.removeProperty('--toast-block'); button.remove(); });
update();
return button;
}
+32 -1
View File
@@ -261,7 +261,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
}
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 140px; /* D42, D187: clear of the toast and the chip above it */ }
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
@@ -505,3 +505,34 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
overflow: hidden; overflow-wrap: anywhere;
}
}
/* D186 (#531, beyond the concept): the selection bars stay in view while you select. This one rule is their position. `--selection-bar-block`
(sticky-chrome.mjs) is the room a shown bar takes; a focused card control and the Images panel keep clear of it
(scroll-margin on them, not scroll-padding on the root, which would scroll the page to reveal the bar's own buttons). At one
column the bar is one row that scrolls sideways, so it does not take a fifth of a phone screen. */
#bulkbar, #library-bulkbar { position: sticky; inset-block-start: 8px; z-index: 5; }
:is(#image-grid, .library-grid) :is(article, a, button, input, select, textarea, [tabindex]) { scroll-margin-block-start: var(--selection-bar-block, 0px); }
.detail-panel { inset-block-start: max(20px, var(--selection-bar-block, 0px)); max-block-size: calc(100dvh - 20px - max(20px, var(--selection-bar-block, 0px))); /* D186 */ }
@media (max-width: 700px) {
.detail-panel { max-block-size: none; /* D186 */ }
.bulkbar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; /* D186 */ }
.bulkbar > * { flex: none; /* D186 */ }
}
@container images-page (inline-size < 600px) { .detail-panel { max-block-size: none; /* D186 */ } }
/* D187 (#531, beyond the concept): Back to top, in the toast's column. It rests above a two-line toast, so the usual
toast does not move it under a finger, and rises only for a taller one. */
.ca-tools .back-to-top {
--toast-rest: 70px;
position: fixed; inset-inline-start: 50%; transform: translateX(-50%); z-index: 10;
inset-block-end: calc(22px + max(var(--toast-rest), var(--toast-block, 0px)));
padding: 6px 12px; border: 1px solid var(--ca-line); border-radius: 999px; background: var(--ca-surface);
box-shadow: 0 8px 35px #0004; font-size: 12px; white-space: nowrap;
}
@media (max-width: 700px) { .ca-tools .back-to-top { inset-block-end: calc(12px + max(var(--toast-rest), var(--toast-block, 0px))); } }
@media (pointer: coarse) { .ca-tools .back-to-top { min-block-size: 40px; } }
/* D188 (#531, beyond the concept): the Resolve panel stays with you as the Images panel does. */
.review-panel { position: sticky; inset-block-start: 20px; }
@media (max-width: 700px) { .review-panel { position: static; } }
@media (min-width: 701px) and (max-height: 720px) { .review-panel { max-block-size: calc(100dvh - 40px); overflow: auto; } }