Compare commits

...
Author SHA1 Message Date
Vito Sansevero 9d4620e2bb test(vocabulary): reload with the tag window at its top; one arrival appends one page (#530)
Firefox restores a scrolled window on reload, and its end then appends a page before the check.
2026-10-30 16:29:05 -07:00
Vito Sansevero 049a4ec91c Merge remote-tracking branch 'origin/main' into fix/530-layout-tags-system 2026-10-30 16:09:55 -07:00
Vito Sansevero f060a39ace fix(workspace): 18px gap between a two-column row and the surface after it (#530)
Recovery policy touched the Backup & export cards, in the concept and on main.
Measured for every System section and Tags at 1600 and 390 px.
2026-10-30 12:28:52 -07:00
Vito Sansevero d73288b37d fix(workspace): mark the D182 and D184 overlay rules (#530) 2026-10-30 10:27:17 -07:00
Vito Sansevero ff98f33079 fix(workspace): level two-column cards, window the Reusable tags list, full-width System (#530)
D182: the second .two-col card no longer takes the 18px .surface + .surface margin.
D183, D185: Reusable tags scrolls in its own window with a sticky header; the end of the
list inside the window loads the next page and Load more stays below it.
D184: System sections use the full content width.
2026-10-30 09:39:19 -07:00
8 changed files with 277 additions and 18 deletions
+23
View File
@@ -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
+14
View File
@@ -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();
}
+2 -2
View File
@@ -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);
+2
View File
@@ -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);
});
+12 -8
View File
@@ -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);
+13 -8
View File
@@ -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');
+16
View File
@@ -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. */