diff --git a/TESTING.md b/TESTING.md index 7c015897..ebeb15da 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2936,6 +2936,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 diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 19edf093..604980c2 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -111,9 +111,23 @@ async function selectTheme(page,theme) { } /** The concept at `#` 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(); } diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index a8b59d2e..37b66f78 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -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); diff --git a/tests/browser/vocabulary-visual.spec.mjs b/tests/browser/vocabulary-visual.spec.mjs index 45b47a4a..dd9fb930 100644 --- a/tests/browser/vocabulary-visual.spec.mjs +++ b/tests/browser/vocabulary-visual.spec.mjs @@ -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. */ diff --git a/tests/browser/workspace-layout-tags-system.spec.mjs b/tests/browser/workspace-layout-tags-system.spec.mjs new file mode 100644 index 00000000..64f8f9c1 --- /dev/null +++ b/tests/browser/workspace-layout-tags-system.spec.mjs @@ -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); +}); diff --git a/tests/browser/workspace-vocabulary.spec.mjs b/tests/browser/workspace-vocabulary.spec.mjs index 4901c3e4..d7e0cb11 100644 --- a/tests/browser/workspace-vocabulary.spec.mjs +++ b/tests/browser/workspace-vocabulary.spec.mjs @@ -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); diff --git a/web/js/workspace/vocabulary.mjs b/web/js/workspace/vocabulary.mjs index 4048196c..ac49b4ec 100644 --- a/web/js/workspace/vocabulary.mjs +++ b/web/js/workspace/vocabulary.mjs @@ -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'); diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index c53c3d61..f08839bd 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -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. */