Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d4620e2bb | ||
|
|
049a4ec91c | ||
|
|
f060a39ace | ||
|
|
d73288b37d | ||
|
|
ff98f33079 |
+23
@@ -2928,6 +2928,29 @@ concept's danger "Archive…" maps to "Archive variant…" followed by a "Delete
|
||||
Tests: `tests/browser/workspace-library-delete.spec.mjs` (#481 cases), and `workspace-library-editor.spec.mjs` (the
|
||||
hand-off and the comparisons) and `library-editor-visual.spec.mjs` with the renamed button. JS only: no restart.
|
||||
|
||||
### Card tops, the Reusable tags window and System's width (#530)
|
||||
|
||||
Found in the maintainer's test of the cutover branch; each departs from the concept, whose files are not edited. The
|
||||
comparisons add `CONCEPT_LAYOUT_DEVIATIONS` (`concept-inventory.mjs`, four rules: card margins, System limit, tag header
|
||||
background and line) to the opened concept page, so the boxes that follow from them are still compared. The new spec pins the
|
||||
real values and that the unmapped concept keeps its 18px offset.
|
||||
|
||||
- **D182**: the second card of a `.two-col` starts level with the first. The concept's `.surface + .surface` 18px margin
|
||||
also applies inside its grid, which put Categories and Workspace preferences 18px lower. Stacked cards are now one 18px
|
||||
gap apart instead of 36px. A surface after a two-column row (Backup & export's Recovery policy) touched the row in the
|
||||
concept and on main; it now sits 18px below it.
|
||||
- **D183**: Reusable tags scrolls in its own window inside the card (`max(280px, 100dvh - 500px)`), so a long vocabulary no
|
||||
longer runs the page off screen. The column headers stick to the top of the window, with the surface background and a
|
||||
drawn line. Load more stays below the window; the end of the list inside the window (`.vocabulary-end`, observed with
|
||||
the window as root) appends the next page. A new search or sort returns the window to its top. Reaching Load more by
|
||||
keyboard no longer appends on its own; Enter does. A rename or merge reloads page one, so the window returns to its top.
|
||||
- **D184**: `.settings-layout` has no 1000px limit, so every System section uses the full content width, as the section
|
||||
nav and the other pages do.
|
||||
- **D185**: on touch and at 390px the same window stays shorter than the screen and overscroll chains to the page, so a
|
||||
swipe outside the window, or past its top, moves the page.
|
||||
|
||||
Measured in `workspace-layout-tags-system.spec.mjs`; the swipe case runs in the touch project.
|
||||
|
||||
### Prompt page heading on one row (#524)
|
||||
|
||||
**D164** (beyond the concept, which never has four heading actions beside a long title): from 951px, the concept's
|
||||
|
||||
@@ -111,9 +111,23 @@ async function selectTheme(page,theme) {
|
||||
}
|
||||
|
||||
/** The concept at `#<route>` with its own Theme select. */
|
||||
/**
|
||||
* The recorded layout deviations (#530), applied to the opened concept page and never to its files, so every box that
|
||||
* follows from them (wider columns, wrapped text, row heights) is compared rather than excused path by path.
|
||||
* Every other measurement still differs if production moves. workspace-layout-tags-system.spec.mjs pins the real values
|
||||
* and that the concept itself still has the old ones.
|
||||
* D182: the second `.two-col` card starts level, and a surface after a row is 18px below it. D184: no 1000px limit on System. D183: the sticky tag header's background and drawn line.
|
||||
*/
|
||||
export const CONCEPT_LAYOUT_DEVIATIONS=`.two-col > .surface + .surface { margin-top: 0; }
|
||||
.two-col + .surface { margin-top: 18px; }
|
||||
.settings-layout { max-width: none; }
|
||||
.two-col > .surface > .table-wrap thead th { background: var(--ca-surface); border-bottom-color: transparent; }`;
|
||||
export const mapConceptLayout=page=>page.addStyleTag({content:CONCEPT_LAYOUT_DEVIATIONS});
|
||||
|
||||
export async function openConcept(page,route,theme,motion='no-preference') {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#${route}`);
|
||||
await mapConceptLayout(page);
|
||||
await selectTheme(page,theme);
|
||||
await expect(page.locator('#main #page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ function measureSystem(mainSelector) {
|
||||
const rect = node.getBoundingClientRect(), base = origin.getBoundingClientRect();
|
||||
return { left: Math.round(rect.left - base.left), width: Math.round(rect.width) };
|
||||
};
|
||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. This pins the
|
||||
// concept's `.surface + .surface` 18px offset, which concept.css applies inside `.two-col` too (copied, not ruled).
|
||||
// A surface's top: from `.two-col` beside its neighbour, else from the bottom of the surface above it. D182 (#530)
|
||||
// levels the second `.two-col` card, on both sides through mapConceptLayout; the real values are in workspace-layout-tags-system.spec.
|
||||
const top = node => {
|
||||
const rect = node.getBoundingClientRect(), previous = node.previousElementSibling?.getBoundingClientRect();
|
||||
if (previous && rect.top >= previous.bottom) return Math.round(rect.top - previous.bottom);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {mapConceptLayout} from './concept-inventory.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
@@ -116,6 +117,7 @@ const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Appl
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
async function openConceptRoute(page,route,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
|
||||
await mapConceptLayout(page);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {seedConcept,CONCEPT_URL} from './concept-inventory.mjs';
|
||||
|
||||
// #530: measured geometry for three deviations from the concept, found in the maintainer's test of the cutover branch.
|
||||
// D182 two-column card tops line up, D183 Reusable tags scrolls in its own window (D185 on touch and at 390px),
|
||||
// D184 System uses the full content width.
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const TAGS=`${workspace}#/tags`;
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
const cards=page=>main(page).locator('.two-col > section.surface');
|
||||
const tagWindow=page=>main(page).locator('.two-col .table-wrap');
|
||||
const rows=page=>tagWindow(page).locator('tbody tr');
|
||||
const loadMore=page=>main(page).getByRole('button',{name:'Load more tags',exact:true});
|
||||
const boxes=locator=>locator.evaluateAll(nodes=>nodes.map(node=>{const {top,bottom,left,right,width}=node.getBoundingClientRect();return {top,bottom,left,right,width};}));
|
||||
const seedTags=async(request,data={})=>expect(await request.post('/__test__/vocabulary/reset',{data})).toBeOK();
|
||||
const nextFrame=page=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
|
||||
|
||||
test.beforeEach(async({page,browserName})=>{
|
||||
await startCoverage(page,browserName);
|
||||
page.layoutErrors=[];page.on('pageerror',error=>page.layoutErrors.push(error.message));
|
||||
});
|
||||
test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.layoutErrors).toEqual([]);});
|
||||
|
||||
test('two-column cards start on the same line; the concept still has the 18px offset (D182)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
await expect(main(page).locator('.summary-metrics')).toBeVisible();
|
||||
const [health,preferences]=await boxes(cards(page));
|
||||
expect(preferences.top).toBe(health.top);
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
const [tags,categories]=await boxes(cards(page));
|
||||
expect(categories.top).toBe(tags.top);
|
||||
// The deviation is real: the immutable concept keeps its offset.
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#tags`);
|
||||
const concept=await boxes(page.locator('.two-col > section.surface'));
|
||||
expect(concept).toHaveLength(2);
|
||||
expect(Math.round(concept[1].top-concept[0].top)).toBe(18);
|
||||
});
|
||||
|
||||
test('stacked cards keep one 18px gap at 390px (D182)',async({page,request})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
const [tags,categories]=await boxes(cards(page));
|
||||
expect(Math.round(categories.top-tags.bottom)).toBe(18);
|
||||
});
|
||||
|
||||
test('System sections use the full content width, as Inspect does (D184)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1920,height:1100});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
for(const section of ['overview','appearance','sources','maintenance']) {
|
||||
await page.goto(`${workspace}#/system/${section}`);
|
||||
const layout=main(page).locator('.settings-layout');
|
||||
await expect(layout).toBeVisible();
|
||||
const [[box],[nav]]=await Promise.all([boxes(layout),boxes(main(page).locator(':scope > .section-nav'))]);
|
||||
expect([section,box.left,box.width]).toEqual([section,nav.left,nav.width]);
|
||||
}
|
||||
await page.goto(`${workspace}#/system/overview`);
|
||||
await expect(main(page).locator('.summary-metrics')).toBeVisible();
|
||||
const [[metrics],[columns],[nav]]=await Promise.all([boxes(main(page).locator('.summary-metrics')),boxes(main(page).locator('.settings-layout > .two-col')),boxes(main(page).locator(':scope > .section-nav'))]);
|
||||
expect(metrics.right).toBeCloseTo(nav.right,0);
|
||||
expect(columns.right).toBeCloseTo(nav.right,0);
|
||||
await page.goto(`${workspace}#/inspect`);
|
||||
await expect(main(page).locator('section.surface').first()).toBeVisible();
|
||||
const [inspect]=await boxes(main(page).locator('section.surface').first());
|
||||
expect(inspect.width).toBeCloseTo(nav.width,0);
|
||||
});
|
||||
|
||||
// Every System section and the Tags page: blocks stacked in one column are never closer than the concept's 18px surface
|
||||
// gap. Between a `.two-col` and the surface after it the concept has no gap at all (D182).
|
||||
const SECTIONS=['overview','appearance','sources','integrations','tagging','maintenance','backups','diagnostics'];
|
||||
const stackGaps=host=>host.evaluate(node=>{
|
||||
const blocks=[...node.children].filter(child=>child.matches('.surface,.two-col')&&child.getClientRects().length);
|
||||
return blocks.slice(1).map((block,index)=>[blocks[index].className,block.className,Math.round(block.getBoundingClientRect().top-blocks[index].getBoundingClientRect().bottom)]);
|
||||
});
|
||||
test('every stacked card has one 18px gap, including after a two-column row (D182)',async({page,request})=>{
|
||||
for(const [width,height] of [[1600,1100],[390,844]]) {
|
||||
await page.setViewportSize({width,height});
|
||||
await seedConcept(request);await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
for(const section of SECTIONS) {
|
||||
await page.goto('about:blank');await page.goto(`${workspace}#/system/${section}`);
|
||||
const layout=main(page).locator('.settings-layout');
|
||||
await expect(layout.locator(':scope > .surface, :scope > .two-col').first()).toBeVisible();
|
||||
for(const gap of await stackGaps(layout)) expect([width,section,...gap]).toEqual([width,section,gap[0],gap[1],18]);
|
||||
}
|
||||
await seedTags(request);
|
||||
await page.goto('about:blank');await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
for(const gap of await stackGaps(main(page))) expect([width,'tags',...gap]).toEqual([width,'tags',gap[0],gap[1],18]);
|
||||
}
|
||||
// The deviation is real: the immutable concept has no gap after its Backup & export columns.
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await page.goto('about:blank');await page.goto(`${CONCEPT_URL}#system`);
|
||||
await page.locator('#main .section-nav').getByRole('button',{name:'Backup & export',exact:true}).click();
|
||||
const policy=page.locator('#main .settings-layout > section.surface');await expect(policy).toBeVisible();
|
||||
expect(await policy.evaluate(node=>Math.round(node.getBoundingClientRect().top-node.previousElementSibling.getBoundingClientRect().bottom))).toBe(0);
|
||||
});
|
||||
|
||||
test('a long tag list scrolls in its own window and the page does not grow (D183)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const size=()=>tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight,scrollTop:node.scrollTop}));
|
||||
const before=await size();
|
||||
expect(before.scrollHeight).toBeGreaterThan(before.clientHeight);
|
||||
expect(before.clientHeight).toBeGreaterThanOrEqual(280);
|
||||
const [tagCard]=await boxes(cards(page));
|
||||
expect(tagCard.bottom).toBeLessThanOrEqual(1100);
|
||||
await expect(loadMore(page)).toBeInViewport();
|
||||
|
||||
// The header stays at the top of the window while the rows move under it.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=600;});await nextFrame(page);
|
||||
const [[head],[frame]]=await Promise.all([boxes(tagWindow(page).locator('thead th').first()),boxes(tagWindow(page))]);
|
||||
expect(Math.round(head.top)).toBe(Math.round(frame.top));
|
||||
|
||||
// Reaching the end of the window appends one page; the page itself did not scroll.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=node.scrollHeight;});
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
expect(await page.evaluate(()=>window.scrollY)).toBe(0);
|
||||
// One arrival is one page: the appended rows push the end away, so pages do not chain while more remain.
|
||||
await nextFrame(page);await nextFrame(page);
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
await expect(loadMore(page)).toBeVisible();
|
||||
|
||||
// Load more works from below the window, with the window not at its end.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await loadMore(page).click();
|
||||
await expect(rows(page)).toHaveCount(211+19);
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
|
||||
// A new sort is a new list: the window returns to its top.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=800;});await nextFrame(page);
|
||||
expect((await size()).scrollTop).toBe(800);
|
||||
await main(page).getByRole('combobox',{name:'Sort tags',exact:true}).selectOption({index:1});
|
||||
await expect.poll(async()=>(await size()).scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
test('a short tag list has no inner scrolling (D183)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await seedTags(request);
|
||||
await page.goto(TAGS);await expect(rows(page).first()).toBeVisible();
|
||||
await page.setViewportSize({width:1600,height:2000});await nextFrame(page);
|
||||
const {scrollHeight,clientHeight}=await tagWindow(page).evaluate(node=>({scrollHeight:node.scrollHeight,clientHeight:node.clientHeight}));
|
||||
expect(scrollHeight).toBe(clientHeight);
|
||||
});
|
||||
|
||||
test('at 390px the window fits the screen, actions stay reachable and the page still scrolls (D185)',async({page,request})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const [frame]=await boxes(tagWindow(page));
|
||||
expect(frame.bottom-frame.top).toBeLessThan(844);
|
||||
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
|
||||
// The last row's actions can be scrolled to inside the window and fit its width.
|
||||
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
|
||||
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
|
||||
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
|
||||
expect(button.right).toBeLessThanOrEqual(after.right);
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await expect(loadMore(page)).toBeAttached();
|
||||
await loadMore(page).scrollIntoViewIfNeeded();await expect(loadMore(page)).toBeInViewport();
|
||||
});
|
||||
|
||||
test('a swipe moves the window inside it and the page outside it (D185)',async({page,request,browserName},testInfo)=>{
|
||||
test.skip(browserName!=='chromium'||testInfo.project.name!=='touch','needs a touch-emulating Chromium');
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await seedTags(request,{extra_tags:211});
|
||||
await page.goto(TAGS);
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
await tagWindow(page).evaluate(node=>node.scrollIntoView({block:'start'}));await nextFrame(page);
|
||||
// A real touch sequence: the browser decides what scrolls.
|
||||
const swipe=async(x,y,distance)=>{
|
||||
const client=await page.context().newCDPSession(page);
|
||||
const touch=(type,at)=>client.send('Input.dispatchTouchEvent',{type,touchPoints:type==='touchEnd'?[]:[{x,y:at}]});
|
||||
await touch('touchStart',y);
|
||||
for(let step=1;step<=10;step++) {await touch('touchMove',y+distance*step/10);await nextFrame(page);}
|
||||
await touch('touchEnd');
|
||||
await client.detach();await nextFrame(page);
|
||||
};
|
||||
const [frame]=await boxes(tagWindow(page));
|
||||
const y=Math.round(frame.top+(frame.bottom-frame.top)/2);
|
||||
const pageTop=await page.evaluate(()=>window.scrollY);
|
||||
await swipe(195,y,-150);
|
||||
await expect.poll(()=>tagWindow(page).evaluate(node=>node.scrollTop)).toBeGreaterThan(50);
|
||||
expect(await page.evaluate(()=>window.scrollY)).toBe(pageTop);
|
||||
// From the top of the window a downward swipe chains to the page, so a phone is never trapped in the list.
|
||||
await tagWindow(page).evaluate(node=>{node.scrollTop=0;});await nextFrame(page);
|
||||
await swipe(195,y,200);
|
||||
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBeLessThan(pageTop);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs';
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {mapConceptLayout} from './concept-inventory.mjs';
|
||||
import {armUnseenRestores,unseenRestore,followRestore,SEEN_RESTORE_PILL,SEEN_RESTORE_TOAST} from './unseen-restore.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,d149Of,toolbarInventoryOf} from './vocabulary-canonical-comparison.mjs';
|
||||
@@ -134,7 +135,9 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
await rows(page).last().getByRole('button',{name:'Rename / merge',exact:true}).evaluate(button=>button.focus({preventScroll:true}));
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
// Focus scrolled the end of the list into view, which appends the next page.
|
||||
// D183: Load more sits below the tag window, so reaching it does not move the window's end into view.
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(rows(page)).toHaveCount(200);
|
||||
await expect(loadMore(page)).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
@@ -142,11 +145,12 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
|
||||
await expect(loadMore(page)).toHaveCount(0);
|
||||
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
|
||||
// Reload at the top so restored scroll does not append before the check.
|
||||
await page.evaluate(()=>window.scrollTo(0,0));
|
||||
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
|
||||
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
|
||||
await page.reload();
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
const scroll=top=>page.evaluate(y=>new Promise(resolve=>{window.scrollTo(0,y ?? document.documentElement.scrollHeight);requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||||
// D183: the end of the list arrives inside the tag window.
|
||||
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
|
||||
for(const count of [200,230]) {
|
||||
// Each new arrival of the end of the list appends one page.
|
||||
await scroll(0);await scroll(null);
|
||||
@@ -206,12 +210,12 @@ test('route disposal aborts requests and removes observers',async({page})=>{
|
||||
await page.route('**/prompt_manager/vocabulary/**',route=>{held.push(route);});
|
||||
await page.goto(route);
|
||||
await expect.poll(()=>page.evaluate(()=>window.__vocabularySignals.length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).length)).toBe(2);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).length)).toBe(2);
|
||||
await expect(main(page).getByText('Loading…',{exact:true})).toHaveCount(2);
|
||||
await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name: /^Prompt library(?:\s*[\d,]+)?$/}).click();
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Prompt library');
|
||||
expect(await page.evaluate(()=>window.__vocabularySignals.map(signal=>signal.aborted))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>name.includes('vocabulary-continuation'))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
expect(await page.evaluate(()=>window.__vocabularyObservers.filter(item=>item.targets.some(name=>/vocabulary-(continuation|end)/.test(name))).map(item=>item.disconnected))).toEqual([true,true]);
|
||||
await expect(main(page).locator('.two-col')).toHaveCount(0);
|
||||
expect(await page.evaluate(()=>document.getElementById('workspace-main').classList.contains('vocabulary-main'))).toBe(false);
|
||||
await page.unroute('**/prompt_manager/vocabulary/**');
|
||||
@@ -775,7 +779,7 @@ const SIZES=[[1600,1100],[390,844]];
|
||||
const MOTIONS=['no-preference','reduce'];
|
||||
async function openConceptTags(page,theme,motion) {
|
||||
await page.emulateMedia({reducedMotion:motion});
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||||
}
|
||||
@@ -791,7 +795,7 @@ async function conceptToolbar(page,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#library`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
const inventory=await page.locator('#main search.toolbar').evaluate(toolbarInventoryOf);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.goto('about:blank');await page.goto(`${concept}#tags`);await mapConceptLayout(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
return inventory;
|
||||
}
|
||||
const tagToolbar=page=>page.locator('#workspace-main .two-col > section.surface:first-child > search.toolbar').evaluate(toolbarInventoryOf);
|
||||
|
||||
@@ -79,12 +79,12 @@ function categoryRow(category) {
|
||||
|
||||
/**
|
||||
* One bounded list: rows append as pages arrive, the end of the list loads the
|
||||
* next page each time it scrolls into view, and `Load more` stays available while
|
||||
* more remain. Only a new intersection appends: when scroll anchoring keeps the end
|
||||
* next page each time it scrolls into view (of `scroller` when the list has its own window, D183), and `Load more`
|
||||
* stays available while more remain. Only a new intersection appends: when scroll anchoring keeps the end
|
||||
* in view (stacked layout below 700px), pages do not chain on their own. Before any row arrives (loading, failed) the list shows no rows, so a
|
||||
* failed read never looks like zero counts.
|
||||
*/
|
||||
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query}) {
|
||||
function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query, scroller = null}) {
|
||||
const copy = COPY[kind];
|
||||
const empty = el('p', copy.empty, 'empty');
|
||||
const continuation = el('div', undefined, 'vocabulary-continuation');
|
||||
@@ -96,6 +96,9 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
const more = createButton(copy.more);
|
||||
continuation.append(status, alert, retry, more);
|
||||
after.after(empty, continuation);
|
||||
// D183: a list with its own window (`scroller`) ends inside that window; Load more stays reachable below it.
|
||||
const end = scroller ? el('div', undefined, 'vocabulary-end') : continuation;
|
||||
if (scroller) scroller.append(end);
|
||||
const items = new WeakMap();
|
||||
const listeners = new Set();
|
||||
let rendered = 0, first = null;
|
||||
@@ -103,14 +106,16 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
function maybeLoad() {
|
||||
const {state} = stream;
|
||||
if (scope.signal.aborted || !state.hasMore || state.loading || state.error || state.resetRequired) return;
|
||||
const bounds = continuation.getBoundingClientRect();
|
||||
if (bounds.top <= window.innerHeight + LOAD_MARGIN && bounds.bottom >= -LOAD_MARGIN) stream.loadMore();
|
||||
const bounds = end.getBoundingClientRect();
|
||||
const area = scroller?.getBoundingClientRect() ?? {top: 0, bottom: window.innerHeight};
|
||||
if (bounds.top <= area.bottom + LOAD_MARGIN && bounds.bottom >= area.top - LOAD_MARGIN) stream.loadMore();
|
||||
}
|
||||
function render(state) {
|
||||
// A reset starts again from page one; otherwise pages only append.
|
||||
if (state.items.length < rendered || (rendered && state.items[0] !== first)) {
|
||||
rowsHost.replaceChildren();
|
||||
rendered = 0;
|
||||
if (scroller) scroller.scrollTop = 0;
|
||||
}
|
||||
const appended = [];
|
||||
for (const item of state.items.slice(rendered)) {
|
||||
@@ -146,8 +151,8 @@ function mountList(scope, api, kind, {content, rowsHost, renderRow, after, query
|
||||
if (typeof IntersectionObserver === 'function') {
|
||||
observer = new IntersectionObserver(entries => {
|
||||
if (entries.some(entry => entry.isIntersecting)) maybeLoad();
|
||||
}, {rootMargin: `${LOAD_MARGIN}px`});
|
||||
observer.observe(continuation);
|
||||
}, {root: scroller, rootMargin: `${LOAD_MARGIN}px`});
|
||||
observer.observe(end);
|
||||
}
|
||||
scope.cleanup(() => {
|
||||
stream.dispose();
|
||||
@@ -245,7 +250,7 @@ export function mountVocabulary(main, api, scope) {
|
||||
grid.append(tagSurface, categorySurface);
|
||||
main.append(grid);
|
||||
|
||||
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read()});
|
||||
const tags = mountList(scope, api, 'tags', {content: wrap, rowsHost: body, renderRow: tagRow, after: wrap, query: tools.read(), scroller: wrap});
|
||||
let announcing = false;
|
||||
tags.subscribe(state => {
|
||||
if (state.untaggedCount !== null) untagged.row.querySelector('p').textContent = countOf(state.untaggedCount, 'positive prompt');
|
||||
|
||||
@@ -107,6 +107,14 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.stat dd { font-size: 26px; }
|
||||
}
|
||||
|
||||
/* D182 (#530, the concept has the offset): the second card of a `.two-col` starts level with the first. The grid gap
|
||||
already spaces them; `.surface + .surface` is for surfaces stacked in plain flow. */
|
||||
.two-col > .surface + .surface { margin-top: 0; /* D182 */ }
|
||||
/* D182: a surface after a two-column row has the same gap as stacked surfaces; the concept has none there. */
|
||||
.two-col + .surface { margin-top: 18px; /* D182 */ }
|
||||
/* D184 (#530, the concept stops System at 1000px): every System section uses the full content width, as other pages do. */
|
||||
.settings-layout { max-width: none; /* D184 */ }
|
||||
|
||||
/* Tags & categories (#245): real vocabulary has unbounded names and bounded lists, which the concept's short samples
|
||||
never show. Proven by the 390px layout and long-name cases in workspace-vocabulary.spec.mjs. */
|
||||
.vocabulary-main :where(.two-col, .surface, .table-wrap, .operation > div) { min-width: 0; }
|
||||
@@ -118,6 +126,14 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
.vocabulary-main .vocabulary-continuation { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding-block: 15px 0; overflow-anchor: none; }
|
||||
.vocabulary-main .vocabulary-continuation p { margin: 0; flex: 1 1 200px; font-size: 12px; color: var(--ca-muted); }
|
||||
.vocabulary-main .vocabulary-continuation .vocabulary-error { color: var(--ca-error-text); }
|
||||
/* D183 (#530, beyond the concept, whose sample list is short): Reusable tags scrolls in its own window and the page keeps
|
||||
its height. The first term is the smallest usable window; the subtraction leaves the heading, the card's own rows and
|
||||
Load more on screen. D185: on touch and at 390px the window stays shorter than the screen and overscroll chains to the
|
||||
page (the default), so a swipe is never trapped in the list. A collapsed border does not stick, so the header draws its
|
||||
line instead of its border. */
|
||||
.vocabulary-main .table-wrap { overflow: auto; max-block-size: max(280px, calc(100dvh - 500px)); scroll-padding-block-start: 44px; }
|
||||
.vocabulary-main .table-wrap thead th { position: sticky; inset-block-start: 0; z-index: 1; background: var(--ca-surface); border-bottom-color: transparent; box-shadow: inset 0 -1px 0 var(--line); }
|
||||
.vocabulary-main .vocabulary-end { block-size: 1px; overflow-anchor: none; }
|
||||
@media (max-width: 700px) {
|
||||
/* One column: the Tag column has no fixed minimum and Actions asks only for its stacked width (the concept's 390px
|
||||
rows stack Browse over Rename / merge), so a wider fallback font wraps the name instead of hiding an action. */
|
||||
|
||||
Reference in New Issue
Block a user