diff --git a/TESTING.md b/TESTING.md index 312671e3..4e37aba5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5705,6 +5705,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 diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index 8851c1f1..55866eab 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -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'); diff --git a/tests/browser/library-concept.spec.mjs b/tests/browser/library-concept.spec.mjs index b6887c86..19536b7a 100644 --- a/tests/browser/library-concept.spec.mjs +++ b/tests/browser/library-concept.spec.mjs @@ -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); diff --git a/tests/browser/tagging-canonical-comparison.mjs b/tests/browser/tagging-canonical-comparison.mjs index a959bc2f..ef112625 100644 --- a/tests/browser/tagging-canonical-comparison.mjs +++ b/tests/browser/tagging-canonical-comparison.mjs @@ -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'); diff --git a/tests/browser/workspace-sticky-chrome.spec.mjs b/tests/browser/workspace-sticky-chrome.spec.mjs new file mode 100644 index 00000000..2fb311d7 --- /dev/null +++ b/tests/browser/workspace-sticky-chrome.spec.mjs @@ -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(); +}); + diff --git a/tests/js/workspace-images-selection.test.js b/tests/js/workspace-images-selection.test.js index 0aa18166..4dd4715a 100644 --- a/tests/js/workspace-images-selection.test.js +++ b/tests/js/workspace-images-selection.test.js @@ -76,6 +76,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'); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index ba112c10..eb695acc 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -718,5 +718,11 @@ "branches": 95, "functions": 98, "statements": 97 + }, + "workspace/sticky-chrome.mjs": { + "lines": 80, + "branches": 60, + "functions": 80, + "statements": 80 } } diff --git a/web/js/workspace/images-collections.mjs b/web/js/workspace/images-collections.mjs index 69806cae..5a9bb0b3 100644 --- a/web/js/workspace/images-collections.mjs +++ b/web/js/workspace/images-collections.mjs @@ -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)); diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index b9c7c3ce..aed2ab12 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -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(); diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 354a7895..1527d623 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -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 { diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 88aaae47..43ca8dcc 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -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 }); diff --git a/web/js/workspace/library-selection.mjs b/web/js/workspace/library-selection.mjs index b6bfb082..92e6f254 100644 --- a/web/js/workspace/library-selection.mjs +++ b/web/js/workspace/library-selection.mjs @@ -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) { diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index a7238296..164f49d7 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -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; diff --git a/web/js/workspace/review.mjs b/web/js/workspace/review.mjs index 22fa9d25..ac959a06 100644 --- a/web/js/workspace/review.mjs +++ b/web/js/workspace/review.mjs @@ -314,6 +314,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; diff --git a/web/js/workspace/sticky-chrome.mjs b/web/js/workspace/sticky-chrome.mjs new file mode 100644 index 00000000..c95146ad --- /dev/null +++ b/web/js/workspace/sticky-chrome.mjs @@ -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; +} diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 50892095..c5ec0ec9 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -290,7 +290,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 @@ -542,3 +542,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; } }