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.
This commit is contained in:
Vito Sansevero
2026-10-30 11:30:24 -07:00
parent a6d6129ec6
commit 6c168bf381
16 changed files with 528 additions and 18 deletions
+19
View File
@@ -7152,3 +7152,22 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
`vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive
floors in `frontend-coverage-baseline.json`.
### 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 on a review card brings the panel into view; Info on an image opens
the viewer's details, because the Images panel follows the whole list, past infinite scroll. The removed-images view keeps
today's panel, because the viewer can write there.
+6 -1
View File
@@ -318,6 +318,8 @@ async function behaviorsOf(page,scope) {
await settle(page);
const focusAfterViewer=await activeOf(page);
await page.locator(CARDS).nth(2).locator('[aria-label^="Inspect"]').click();
// D189: at one column Info also opens the viewer's details, over the panel it updates.
if(page.viewportSize().width<=700){await page.keyboard.press('Escape');await expect(page.locator('#viewer-dialog')).toBeHidden();}
await expect.poll(()=>selectedOf(page)).toEqual([2]);
const selected=await selectedOf(page);
const layouts=await layoutsOf(page,scope);
@@ -474,7 +476,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');
+10
View File
@@ -70,6 +70,7 @@ test('native Images keeps colliding filenames distinct and inspects the selected
await page.setViewportSize({width:390,height:844});
for (const [index,source] of [[1,'input'],[0,'output']]) {
await cards(page).nth(index).getByRole('button',{name:'Inspect same.png',exact:true}).click();
await closeInfoViewer(page); // D189
await expectPanelSource(page, source);
await details(page).scrollIntoViewIfNeeded();
await capture(`after-images-source-collision-${source}-390`);
@@ -77,6 +78,13 @@ test('native Images keeps colliding filenames distinct and inspects the selected
expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats);
});
/** D189: at one column Info also opens the viewer's details; the panel is updated behind it. */
async function closeInfoViewer(page) {
await expect(page.locator('#viewer-dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog')).toBeHidden();
}
test('workspace defaults to the native Images collection without a hash route', async ({ page }) => {
await page.goto('/prompt_manager/workspace');
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
@@ -669,6 +677,7 @@ test('mobile Images keeps filters, layouts and selected details within the viewp
for (const layout of ['List', 'Masonry', 'Grid']) {
await page.getByRole('button', { name: layout, exact: true }).click();
await cards(page).last().getByRole('button', { name: 'Inspect same.png', exact: true }).click();
await closeInfoViewer(page); // D189
await expectPanelSource(page, 'input');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
}
@@ -2260,6 +2269,7 @@ test('at 390px in light theme the Set category dialog and the category chip fit
const card = cardOf(page, '0:bulk/0001.png');
// The chip wraps inside the side panel; its origin never splits.
await card.getByRole('button', { name: 'Inspect 0001.png', exact: true }).click();
await closeInfoViewer(page); // D189
const chip = details(page).locator('span.chip', { has: page.locator('span.chip-source') });
await expect(chip).toHaveText(`Image category: ${LONG} · this image`);
const fit = await chip.evaluate(node => {
@@ -0,0 +1,315 @@
// #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, so scrolling to it would run into infinite scroll.
test('at 390px Info on an image opens the viewer with its details; loading the page does not', async ({ page, request }) => {
await page.setViewportSize(PHONE);
await openImages(page, request, 20);
await expect(page.locator('#viewer-dialog')).toBeHidden();
const card = imageCards(page).nth(3);
await card.scrollIntoViewIfNeeded();
const scrolled = await page.evaluate(() => scrollY);
await card.getByRole('button', { name: /^Inspect / }).click();
await expect(page.locator('#viewer-dialog')).toBeVisible();
await expect(page.locator('#viewer-details')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog')).toBeHidden();
await expect(card.getByRole('button', { name: /^Inspect / })).toBeFocused();
expect(await page.evaluate(() => scrollY)).toBe(scrolled);
});
// 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();
});
+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();
+20 -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';
@@ -310,11 +311,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 +328,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 +405,11 @@ 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);
// D189: at one column the panel follows the whole list, past infinite scroll, so Info opens the viewer's details.
if (getComputedStyle(details).position === 'static') previewImage(image);
}
});
function renderVisible() {
@@ -702,8 +709,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 +826,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))); }
@media (max-width: 700px) {
.detail-panel { max-block-size: none; }
.bulkbar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; }
.bulkbar > * { flex: none; }
}
@container images-page (inline-size < 600px) { .detail-panel { max-block-size: none; } }
/* 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; } }