Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239423e0c8 | ||
|
|
99e392436e | ||
|
|
231f8a1c75 | ||
|
|
c4a99d9915 | ||
|
|
6c168bf381 |
+17
@@ -5617,6 +5617,23 @@ npm run --prefix tools/vendor test:workflows -- workspace-system-backups.spec.mj
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
## Sticky chrome (#531)
|
||||
|
||||
Beyond the concept, from the maintainer's test of #528; the concept is unchanged. `workspace-sticky-chrome.spec.mjs` holds these.
|
||||
|
||||
- **D186:** the selection bars (Images `#bulkbar`, Library `#library-bulkbar`; there are no others) are sticky: under the
|
||||
top bar at the top of the page, and at the top of the window once that non-sticky top bar has scrolled away (one rule
|
||||
in workspace.css). While the bar's flow place is above the window, its appearing, resizing or leaving does not move the cards
|
||||
(`keepingPlace`). Focused card controls and the Images panel keep clear of it (`--selection-bar-block`). At 700px and below
|
||||
the bar is one row that scrolls sideways. After a bulk action finishes the page scrolls to the top and refreshes the
|
||||
list; Set category, Delete… and Return to library clear the selection as before, while tagging and collections keep
|
||||
it (D85, D138). Export metadata changes nothing, so it stays put. Single-image actions from the inspector and viewer stay put.
|
||||
- **D187:** Back to top: one shell chip past 200px of scroll, above the toast's spot; it rises only for a toast taller than two lines. It never animates, because the
|
||||
pages' height corrections would cancel a smooth scroll, and focus moves to `#workspace-main`. D42's band grows to 140px.
|
||||
- **D188:** the Needs review panel is sticky like the Images panel, with its own scroll on a window under 720px tall, and static at 700px and below.
|
||||
- **D189:** where a panel is one column (static): Info brings the panel into view. On
|
||||
Images the panel follows the whole list, past infinite scroll, so loading waits for the reader's next move (wheel, touch or key) rather than pushing the panel away.
|
||||
|
||||
## Workspace data fields for the concept pages (#249)
|
||||
|
||||
These fields are additive. The legacy `/prompt_manager/images/output` listing (no
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -374,7 +374,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`the Library se
|
||||
await captureCanonicalState(page,testInfo,`production-library-selection-${width}-${theme}`);
|
||||
await testInfo.attach('library-selection',{body:JSON.stringify({reference,actual},null,2),contentType:'application/json'});
|
||||
expect(reference.bar.buttons.map(button=>button.text),'the concept bar has every Library action').toEqual(['Add tags','Set category','Clear']);
|
||||
expect(actual).toEqual(reference);
|
||||
// D186: at one column the bar is one row that scrolls sideways, with the same buttons.
|
||||
const mapped=width<=700?{...reference,bar:{...reference.bar,flexWrap:'nowrap',buttons:reference.bar.buttons.map((button,index)=>({...button,left:actual.bar.buttons[index].left,top:actual.bar.buttons[index].top}))}}:reference;
|
||||
expect(actual).toEqual(mapped);
|
||||
// The comparison notices a moved checkbox (spec §7 mutation check).
|
||||
await mutate(page,{css:'.prompt-card .image-overlays { inset: 20px 9px auto }'});
|
||||
expect((await measureSelection(page,{card:CARDS,main:'#workspace-main'})).overlay.top).not.toBe(reference.overlay.top);
|
||||
|
||||
@@ -231,6 +231,9 @@ export function mapConceptReview(reference,actual) {
|
||||
const actualActions=findNode(actual.main,'div.dialog-actions');
|
||||
let cardIndex=0;
|
||||
let main={...reference.main,id:actual.main.id};
|
||||
// D188: the Resolve panel is sticky.
|
||||
const actualPanel=findNode(actual.main,'div.gallery-layout')?.children[1];
|
||||
if(actualPanel)main=editNodes(main,'div.gallery-layout',layout=>({...layout,children:layout.children.map((child,index)=>index===1?{...child,position:actualPanel.position}:child)}));
|
||||
let noticeLoss=0;
|
||||
main=editNodes(main,'div.notice',notice=>{
|
||||
const chip=notice.children.find(child=>child.tag==='span.chip');
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// #531 (D186-D189): the selection bars stay in view, a Back to top chip follows the page, and the side panels stay with
|
||||
// you. Beyond the concept; the concept itself is unchanged.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
|
||||
const IMAGES = '/prompt_manager/workspace#/images';
|
||||
const LIBRARY = '/prompt_manager/workspace#/library';
|
||||
const REVIEW = '/prompt_manager/workspace#/review';
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
const imageCards = page => page.locator('#image-grid article[data-image-key]');
|
||||
const promptCards = page => page.locator('article[data-prompt-id]');
|
||||
const imageBar = page => page.locator('#bulkbar > .bulkbar');
|
||||
const libraryBar = page => page.locator('#library-bulkbar > .bulkbar');
|
||||
const chip = page => page.getByRole('button', { name: 'Back to top', exact: true });
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const reviewPanel = page => page.getByRole('region', { name: 'Resolve the association', exact: true });
|
||||
const reviewCards = page => page.locator('.review-queue article.media-card');
|
||||
|
||||
const box = locator => locator.evaluate(node => {
|
||||
const { top, bottom, left, right } = node.getBoundingClientRect();
|
||||
return { top, bottom, left, right };
|
||||
});
|
||||
const overlaps = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||||
/** Scroll the window to `y` and wait for the frame that applied it. */
|
||||
async function scrollToY(page, y) {
|
||||
await page.evaluate(target => new Promise(resolve => {
|
||||
scrollTo({ top: target, behavior: 'instant' });
|
||||
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
||||
}), y);
|
||||
}
|
||||
/** The first card wholly below `below` px, by its index in the list. */
|
||||
async function cardBelow(cards, below) {
|
||||
const count = await cards.count();
|
||||
for (let index = 0; index < count; index++) {
|
||||
const card = cards.nth(index);
|
||||
if ((await box(card)).top <= below) continue;
|
||||
// By its key, not its position: the mounted window moves as the page does.
|
||||
const key = await card.evaluate(node => ['imageKey', 'promptId', 'reviewId'].map(name => [name, node.dataset[name]]).find(([, value]) => value));
|
||||
const attribute = key[0].replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
return cards.page().locator(`article[data-${attribute}=${JSON.stringify(key[1])}]`);
|
||||
}
|
||||
throw new Error(`no card below ${below}`);
|
||||
}
|
||||
async function openImages(page, request, extra = 100) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
await page.goto(IMAGES);
|
||||
await expect(imageCards(page).first()).toBeVisible();
|
||||
}
|
||||
async function openLibrary(page, request, extra = 40) {
|
||||
await expect(await request.post('/__test__/reset', { data: { native_library: true, extra_prompts: extra } })).toBeOK();
|
||||
await page.goto(LIBRARY);
|
||||
await expect(promptCards(page).first()).toBeVisible();
|
||||
}
|
||||
async function openReview(page, request, items = 30) {
|
||||
await expect(await request.post('/__test__/review/reset', { data: { items } })).toBeOK();
|
||||
await page.goto(REVIEW);
|
||||
await expect(reviewPanel(page).getByRole('combobox', { name: 'Sampler context', exact: true })).toBeEnabled();
|
||||
}
|
||||
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
|
||||
|
||||
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
|
||||
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
|
||||
|
||||
for (const viewport of [null, PHONE]) {
|
||||
const label = viewport ? ' at 390px' : '';
|
||||
test(`Images: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
const card = await cardBelow(imageCards(page), 300);
|
||||
const before = (await box(card)).top;
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page).locator('strong')).toHaveText('1 selected');
|
||||
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
|
||||
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
// Keep scrolling and selecting: the bar stays; Clear returns the card to its place too.
|
||||
await scrollToY(page, 1900);
|
||||
expect(inViewport(await box(imageBar(page)), page)).toBe(true);
|
||||
const later = await cardBelow(imageCards(page), 300);
|
||||
await later.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page).locator('strong')).toHaveText('2 selected');
|
||||
const held = (await box(later)).top;
|
||||
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).click();
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
expect(Math.abs((await box(later)).top - held)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test(`Library: selecting far down keeps the bar in view and the card in place${label}`, async ({ page, request }) => {
|
||||
if (viewport) await page.setViewportSize(viewport);
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 2000);
|
||||
await expect(promptCards(page).first()).toBeAttached();
|
||||
const card = await cardBelow(promptCards(page), 250);
|
||||
const before = (await box(card)).top;
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(libraryBar(page).locator('strong')).toHaveText('1 selected');
|
||||
expect(inViewport(await box(libraryBar(page)), page)).toBe(true);
|
||||
expect(Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
expect(await card.isVisible()).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test('the Images panel clears the stuck bar and a focused card is not left under it', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 300)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
await expect.poll(async () => (await box(page.locator('#image-inspector'))).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
|
||||
expect(overlaps(await box(page.locator('#image-inspector')), await box(imageBar(page)))).toBe(false);
|
||||
// A control scrolled to by focus lands below the bar; the bar's own controls do not move the page.
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
await imageBar(page).getByRole('button', { name: 'Clear', exact: true }).focus();
|
||||
expect(await page.evaluate(() => scrollY)).toBe(y);
|
||||
const above = imageCards(page).first().locator('.image-overlays input[type="checkbox"]');
|
||||
await above.focus();
|
||||
expect((await box(above)).top).toBeGreaterThanOrEqual((await box(imageBar(page))).bottom);
|
||||
});
|
||||
|
||||
test('Back to top shows off the top of a page, goes to the top and hands focus to the page', async ({ page, request }) => {
|
||||
await openImages(page, request);
|
||||
await expect(chip(page)).toBeHidden();
|
||||
await scrollToY(page, 1500);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
const loaded = await page.evaluate(() => document.querySelector('#result-count').textContent);
|
||||
await chip(page).click();
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect(chip(page)).toBeHidden();
|
||||
await expect(page.locator('#workspace-main')).toBeFocused();
|
||||
expect(await page.evaluate(() => document.querySelector('#result-count').textContent)).toBe(loaded);
|
||||
await expect(imageCards(page).first()).toBeInViewport();
|
||||
});
|
||||
|
||||
test('Back to top on the virtualized Library lands on the first card and stays there', async ({ page, request }) => {
|
||||
await openLibrary(page, request, 200);
|
||||
// Past 120 loaded prompts the shelf mounts only a window of them (each pass reaches the next page's trigger).
|
||||
await expect.poll(async () => {
|
||||
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' }));
|
||||
return page.locator('article[data-index="0"]').count();
|
||||
}, { timeout: 30000 }).toBe(0);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
await chip(page).click();
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect.poll(async () => Number(await promptCards(page).first().getAttribute('data-index'))).toBe(0);
|
||||
await expect(promptCards(page).first()).toBeInViewport();
|
||||
expect(await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve(scrollY)))))).toBe(0);
|
||||
});
|
||||
|
||||
test('Back to top jumps, under reduced motion and otherwise', async ({ page, request }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await openImages(page, request, 20);
|
||||
await scrollToY(page, 600);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
const after = await chip(page).evaluate(node => { node.click(); return scrollY; });
|
||||
expect(after).toBe(0);
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 1280, height: 720 }, PHONE]) {
|
||||
test(`Back to top moves clear of a toast and of the bar at ${viewport.width}px`, async ({ page, request }) => {
|
||||
await page.setViewportSize(viewport);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await expect(chip(page)).toBeVisible();
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
expect(overlaps(await box(chip(page)), await box(imageBar(page)))).toBe(false);
|
||||
const alone = await box(chip(page));
|
||||
await page.evaluate(() => {
|
||||
document.getElementById('workspace-toast').textContent = 'A much longer message than most, one that keeps going, so that it wraps to more than two lines on the narrowest screen a phone will give this page to show it on, and then some more words again.';
|
||||
});
|
||||
await expect(toast(page)).not.toBeEmpty();
|
||||
await expect.poll(async () => overlaps(await box(chip(page)), await box(toast(page)))).toBe(false);
|
||||
expect((await box(chip(page))).bottom).toBeLessThanOrEqual((await box(toast(page))).top);
|
||||
await expect(chip(page)).toBeInViewport();
|
||||
await page.evaluate(() => { document.getElementById('workspace-toast').textContent = ''; });
|
||||
await expect.poll(async () => Math.round((await box(chip(page))).bottom)).toBe(Math.round(alone.bottom));
|
||||
});
|
||||
}
|
||||
|
||||
test('a stuck bar at 390px is one row that keeps every action reachable', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
expect((await box(imageBar(page))).bottom - (await box(imageBar(page))).top).toBeLessThan(80);
|
||||
const clear = imageBar(page).getByRole('button', { name: 'Clear', exact: true });
|
||||
await clear.scrollIntoViewIfNeeded();
|
||||
await expect(clear).toBeInViewport();
|
||||
await clear.click();
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('the Needs review panel stays with you, and its long content scrolls inside it', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 500 });
|
||||
await openReview(page, request);
|
||||
await scrollToY(page, 700);
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
const rect = await box(reviewPanel(page));
|
||||
expect(rect.top).toBeGreaterThanOrEqual(0);
|
||||
expect(rect.bottom).toBeLessThanOrEqual(500);
|
||||
const card = await cardBelow(reviewCards(page), 100);
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
const name = await card.locator('.media-info').first().textContent();
|
||||
expect(name).toBeTruthy();
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
expect((await box(reviewPanel(page))).top).toBe(rect.top);
|
||||
});
|
||||
|
||||
test('at 390px Info on a lower review card brings the panel into view', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openReview(page, request);
|
||||
await expect(reviewPanel(page)).not.toBeInViewport();
|
||||
const card = reviewCards(page).nth(3);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
await expect(reviewPanel(page)).toBeInViewport();
|
||||
});
|
||||
|
||||
// D189: at one column the Images panel follows the whole list: Info goes there, and a batch doesn't push it away.
|
||||
test('at 390px Info on an image brings its panel into view and loading waits for the next move', async ({ page, request }) => {
|
||||
await page.setViewportSize(PHONE);
|
||||
await openImages(page, request);
|
||||
expect(await page.evaluate(() => scrollY)).toBe(0);
|
||||
await expect(page.locator('#image-inspector')).not.toBeInViewport();
|
||||
let reads = 0;
|
||||
page.on('request', item => { if (new URL(item.url()).pathname.endsWith('/images/recent')) reads++; });
|
||||
const card = imageCards(page).nth(3);
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await card.getByRole('button', { name: /^Inspect / }).click();
|
||||
await expect(page.locator('#image-inspector')).toBeInViewport();
|
||||
await expect(page.locator('#viewer-dialog')).toBeHidden();
|
||||
const held = (await box(page.locator('#image-inspector'))).top;
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
expect((await box(page.locator('#image-inspector'))).top).toBe(held);
|
||||
expect(reads).toBe(0);
|
||||
// The next move releases it: loading resumes.
|
||||
const next = page.waitForRequest(item => new URL(item.url()).pathname.includes('/images'));
|
||||
await page.mouse.wheel(0, 40);
|
||||
await next;
|
||||
});
|
||||
|
||||
// Review's review-state load more is a button, so Library's anchor stays honest through a compensated bar.
|
||||
test('a Library selection does not jump after a pending height correction', async ({ page, request }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 3000);
|
||||
const card = await cardBelow(promptCards(page), 250);
|
||||
await card.locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(libraryBar(page)).toBeVisible();
|
||||
const before = (await box(card)).top;
|
||||
await page.setViewportSize({ width: 1300, height: 800 });
|
||||
await expect(card).toBeAttached();
|
||||
await page.setViewportSize({ width: 1000, height: 800 });
|
||||
await expect.poll(async () => Math.abs((await box(card)).top - before)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
// #531: after a bulk action on a bar, the page shows the refreshed list from the top.
|
||||
async function selectFarDownImages(page, request) {
|
||||
await openImages(page, request);
|
||||
await scrollToY(page, 1500);
|
||||
await (await cardBelow(imageCards(page), 200)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
}
|
||||
const atTop = page => expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
|
||||
const applyDialog = async (page, title, value) => {
|
||||
const dialog = page.getByRole('dialog', { name: title, exact: true });
|
||||
await dialog.locator('#action-input').fill(value);
|
||||
await dialog.getByRole('button', { name: /^(Apply|Add)/ }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
};
|
||||
test('Images: Set category returns to the top of the refreshed list', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
const read = page.waitForRequest(item => item.url().includes('/prompt_manager/images/'));
|
||||
await imageBar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await applyDialog(page, 'Categorize selected images', 'Studio');
|
||||
await read;
|
||||
await atTop(page);
|
||||
await expect(imageBar(page)).toHaveCount(0);
|
||||
await expect(imageCards(page).first()).toBeInViewport();
|
||||
});
|
||||
test('Images: Add tags returns to the top of the refreshed list when its job ends', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
await imageBar(page).getByRole('button', { name: 'Add tags', exact: true }).click();
|
||||
await applyDialog(page, 'Tag selected images', 'sticky-tag');
|
||||
await atTop(page);
|
||||
await expect(imageBar(page)).toBeVisible();
|
||||
});
|
||||
test('Library: Set category and Add tags return to the top of the reloaded shelf', async ({ page, request }) => {
|
||||
await openLibrary(page, request);
|
||||
for (const [button, title, value] of [['Set category', 'Categorize selected prompts', 'Studio'], ['Add tags', 'Tag selected prompts', 'sticky']]) {
|
||||
await scrollToY(page, 2000);
|
||||
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await libraryBar(page).getByRole('button', { name: button, exact: true }).click();
|
||||
await applyDialog(page, title, value);
|
||||
await atTop(page);
|
||||
await expect(libraryBar(page)).toHaveCount(0);
|
||||
await expect(promptCards(page).first()).toBeInViewport();
|
||||
}
|
||||
});
|
||||
test('Images: Delete… returns to the top of the reloaded list', async ({ page, request }) => {
|
||||
await selectFarDownImages(page, request);
|
||||
await imageBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
const dialog = page.locator('dialog[open]');
|
||||
await dialog.getByRole('radio', { name: /^Remove from the library only/ }).check();
|
||||
await dialog.locator('.dialog-actions button.danger').click();
|
||||
await expect(toast(page)).toContainText('Removed 1 image from the library.');
|
||||
await atTop(page);
|
||||
await expect(page.locator('#bulkbar')).toBeEmpty();
|
||||
});
|
||||
test('Library: Delete… returns to the top of the reloaded shelf', async ({ page, request }) => {
|
||||
await openLibrary(page, request);
|
||||
await scrollToY(page, 2000);
|
||||
await (await cardBelow(promptCards(page), 250)).locator('.image-overlays input[type="checkbox"]').check();
|
||||
await libraryBar(page).getByRole('button', { name: 'Delete…', exact: true }).click();
|
||||
await page.locator('dialog[open] .dialog-actions button.danger').click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await atTop(page);
|
||||
await expect(page.locator('#library-bulkbar')).toBeEmpty();
|
||||
});
|
||||
|
||||
@@ -75,6 +75,9 @@ function fakeDOM() {
|
||||
|
||||
/** The bar over `items` with `checked` keys; `options` adds the collection props. */
|
||||
function mountBar(dom, items, checkedKeys, options = {}) {
|
||||
// The bar publishes its height for the sticky chrome (D186); nothing in this DOM ever resizes.
|
||||
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} };
|
||||
document.documentElement ??= { style: { setProperty() {}, removeProperty() {} } };
|
||||
const scope = createScope();
|
||||
const grid = new dom.Element('div');
|
||||
const focusFallback = new dom.Element('h1');
|
||||
|
||||
@@ -700,5 +700,11 @@
|
||||
"branches": 95,
|
||||
"functions": 98,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/sticky-chrome.mjs": {
|
||||
"lines": 80,
|
||||
"branches": 60,
|
||||
"functions": 80,
|
||||
"statements": 80
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -261,7 +261,7 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
}
|
||||
/* #249 D42: a discovery alert a rescan leaves scrolls clear of the concept's fixed toast (22px inset, 12px at 700px and
|
||||
below, two 12px lines at 390px), never under it; the toast itself keeps the concept's placement. */
|
||||
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 96px; /* D42 */ }
|
||||
.options-status { font-size: 12px; padding-inline: 4px; scroll-margin-block-end: 140px; /* D42, D187: clear of the toast and the chip above it */ }
|
||||
|
||||
/* Prompt library cards (#249 PR E): the concept's `article.prompt-card` (s653–708) with real prompts, titles and media. */
|
||||
/* #249 D23: real prompts are far longer than the concept's samples, so the card clamps the positive to two lines; copy
|
||||
@@ -505,3 +505,34 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
overflow: hidden; overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
/* D186 (#531, beyond the concept): the selection bars stay in view while you select. This one rule is their position. `--selection-bar-block`
|
||||
(sticky-chrome.mjs) is the room a shown bar takes; a focused card control and the Images panel keep clear of it
|
||||
(scroll-margin on them, not scroll-padding on the root, which would scroll the page to reveal the bar's own buttons). At one
|
||||
column the bar is one row that scrolls sideways, so it does not take a fifth of a phone screen. */
|
||||
#bulkbar, #library-bulkbar { position: sticky; inset-block-start: 8px; z-index: 5; }
|
||||
:is(#image-grid, .library-grid) :is(article, a, button, input, select, textarea, [tabindex]) { scroll-margin-block-start: var(--selection-bar-block, 0px); }
|
||||
.detail-panel { inset-block-start: max(20px, var(--selection-bar-block, 0px)); max-block-size: calc(100dvh - 20px - max(20px, var(--selection-bar-block, 0px))); /* D186 */ }
|
||||
@media (max-width: 700px) {
|
||||
.detail-panel { max-block-size: none; /* D186 */ }
|
||||
.bulkbar { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: thin; /* D186 */ }
|
||||
.bulkbar > * { flex: none; /* D186 */ }
|
||||
}
|
||||
@container images-page (inline-size < 600px) { .detail-panel { max-block-size: none; /* D186 */ } }
|
||||
|
||||
/* D187 (#531, beyond the concept): Back to top, in the toast's column. It rests above a two-line toast, so the usual
|
||||
toast does not move it under a finger, and rises only for a taller one. */
|
||||
.ca-tools .back-to-top {
|
||||
--toast-rest: 70px;
|
||||
position: fixed; inset-inline-start: 50%; transform: translateX(-50%); z-index: 10;
|
||||
inset-block-end: calc(22px + max(var(--toast-rest), var(--toast-block, 0px)));
|
||||
padding: 6px 12px; border: 1px solid var(--ca-line); border-radius: 999px; background: var(--ca-surface);
|
||||
box-shadow: 0 8px 35px #0004; font-size: 12px; white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 700px) { .ca-tools .back-to-top { inset-block-end: calc(12px + max(var(--toast-rest), var(--toast-block, 0px))); } }
|
||||
@media (pointer: coarse) { .ca-tools .back-to-top { min-block-size: 40px; } }
|
||||
|
||||
/* D188 (#531, beyond the concept): the Resolve panel stays with you as the Images panel does. */
|
||||
.review-panel { position: sticky; inset-block-start: 20px; }
|
||||
@media (max-width: 700px) { .review-panel { position: static; } }
|
||||
@media (min-width: 701px) and (max-height: 720px) { .review-panel { max-block-size: calc(100dvh - 40px); overflow: auto; } }
|
||||
|
||||
Reference in New Issue
Block a user