From 79b6b718efa8e329e08555401a816896ceec58f5 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 17:58:14 -0400 Subject: [PATCH 1/2] fix(tasks): fill board space beside inspector and reveal pagination label --- .../web/von_interface/static/styles.css | 11 ++- tests/browser/taskBoardPagination.cjs | 93 +++++++++++++++++++ tests/frontend/taskPanelLayout.test.js | 3 +- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/browser/taskBoardPagination.cjs diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 099173d0f..295c7375e 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -3536,6 +3536,7 @@ button.unified-search-conversation-main { min-height: 40px; padding: 6px 12px; background: #fff; + color: #0f172a; border: 1px solid #cbd5e1; border-radius: 10px; cursor: pointer; @@ -3606,6 +3607,9 @@ button.unified-search-conversation-main { .global-task-main { min-width: 0; + align-self: stretch; + display: flex; + flex-direction: column; } .task-board-scroll-hint { @@ -3625,7 +3629,10 @@ button.unified-search-conversation-main { .task-list[data-view-mode="board"] { max-width: 100%; - max-height: clamp(420px, 68dvh, 760px); + /* Fill the space beside the inspector without sizing to every loaded card. */ + flex: 1 1 0; + min-height: max(420px, 68dvh); + height: 0; overflow: auto; overscroll-behavior: contain; scrollbar-gutter: stable; @@ -3647,6 +3654,8 @@ button.unified-search-conversation-main { .task-pagination-control { display: flex; + flex-wrap: wrap; + flex-shrink: 0; align-items: center; justify-content: center; gap: 12px; diff --git a/tests/browser/taskBoardPagination.cjs b/tests/browser/taskBoardPagination.cjs new file mode 100644 index 000000000..5e7dfcd17 --- /dev/null +++ b/tests/browser/taskBoardPagination.cjs @@ -0,0 +1,93 @@ +/** Tier 1 rendering fixture: real task panel/CSS, synthetic read-only API pages. */ +const { chromium, expect } = require('@playwright/test'); +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const root = path.resolve('src/frontend/web/von_interface/static'); +const output = path.resolve(process.argv[2] || '.run/task-board-pagination'); +const baseline = process.argv.includes('--baseline'); +const task = i => ({ task_concept_id: `#V#fixture_task_${i}`, title: `Research task ${i}`, description: 'Review the research evidence and record the outcome.', status: 'pending', priority: 'medium' }); +const requests = []; +const stubs = { + '/js/apiService.js': `export const WINDOW_SESSION_HEADER='X-Von-Window-Session'; export const ensureUniqueWindowSessionId=async()=> 'fixture'; export const getUserContext=()=>({user_id:'#V#fixture',org_id:'#V#fixture_org'}); export const getJson=async url=>(await fetch(url)).json(); export const postJson=()=>{throw Error('Unexpected write')}; export const patchJson=postJson; export const deleteJson=postJson;`, + '/js/tabNavigation.js': 'export const activateTab=()=>{};', + '/js/utils/toast.js': 'export const showToast=()=>{};', + '/js/markdownUtils.js': 'export const renderMarkdownViaServer=async()=>"";', +}; +const server = http.createServer((req,res) => { + const url = new URL(req.url,'http://localhost'); + if(url.pathname === '/') { + res.setHeader('Content-Type','text/html'); + res.end('
'); + } else if (stubs[url.pathname]) { + res.setHeader('Content-Type','text/javascript');res.end(stubs[url.pathname]); + } else if(url.pathname === '/api/tasks/') { + const offset = Number(url.searchParams.get('offset')); requests.push(Object.fromEntries(url.searchParams)); + const data = {tasks: Array.from({length:50},(_,i)=>task(offset+i)),count:50,offset,has_more:offset===0}; + res.setHeader('Content-Type','application/json'); + setTimeout(()=>res.end(JSON.stringify(data)),offset ? 600 : 0); + } else if(url.pathname.startsWith('/api/')) { + res.setHeader('Content-Type','application/json'); + res.end(JSON.stringify(url.pathname === '/api/tasks/taxonomy' ? {task_types:[],task_sources:[],defaults:{}} : {})); + } else { + const file = path.join(root,url.pathname); + if(!file.startsWith(root + '/') || !fs.existsSync(file)) {res.statusCode=404;res.end();return;} + res.setHeader('Content-Type',file.endsWith('.css')?'text/css':'text/javascript'); + let source=fs.readFileSync(file,'utf8'); + if(baseline && url.pathname === '/styles.css') source=require('node:child_process').execFileSync('git',['show','HEAD:src/frontend/web/von_interface/static/styles.css'],{encoding:'utf8'}); + res.end(source); + } +}); +(async()=>{ + fs.mkdirSync(output,{recursive:true}); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const browser=await chromium.launch(); + const measurements=[]; + try { + const page=await browser.newPage(); + const errors=[];page.on('pageerror',e=>errors.push(e.message)); + for(const viewport of [{width:1920,height:1080},{width:1536,height:864},{width:1280,height:900}]) { + await page.setViewportSize(viewport); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const more=page.getByRole('button',{name:'Load 50 more',exact:true}); + await expect(more).toBeVisible(); + await expect(page.locator('.task-inspector-card')).toBeVisible(); + const measure=async state=>{ + const result=await page.evaluate(()=>{ + const board=document.querySelector('#globalTaskList'); + const main=document.querySelector('.global-task-main').getBoundingClientRect(); + const inspector=document.querySelector('#globalTaskInspector').getBoundingClientRect(); + const btn=document.querySelector('[data-task-page-action]'); + const style=btn && getComputedStyle(btn); + board.scrollTop=board.scrollHeight; + return {boardHeight:board.clientHeight,scrollTop:board.scrollTop,scrollHeight:board.scrollHeight,mainBottom:main.bottom,inspectorBottom:inspector.bottom,sideBySide:inspector.x>main.x+main.width-1,color:style?.color,background:style?.backgroundColor,overflow:document.documentElement.scrollWidth-innerWidth}; + }); + measurements.push({viewport,state,...result}); + if(!baseline) { + assert(result.scrollTop>0,'loaded cards remain scrollable'); + assert(result.overflow<=1,'board does not overflow the document'); + if(result.sideBySide && state==='open') assert(Math.abs(result.mainBottom-result.inspectorBottom)<2,'board and pagination fill inspector height'); + if(result.color) assert.notEqual(result.color,result.background,'pagination label contrast'); + } + await page.locator('#globalTaskPagination').scrollIntoViewIfNeeded(); + await page.screenshot({path:path.join(output,`${viewport.width}-${state}.png`)}); + }; + await measure('open'); + await more.click(); + await expect(page.getByRole('button',{name:'Loading…',exact:true})).toBeDisabled(); + await expect(page.locator('#globalTaskPagination')).toHaveText('No more tasks to load.'); + const ids=await page.locator('#globalTaskList .task-item[data-task-id]').evaluateAll(nodes=>nodes.map(n=>n.dataset.taskId)); + assert.equal(ids.length,100);assert.equal(new Set(ids).size,100); + for(let i=0;i<100;i++) assert(ids.includes(task(i).task_concept_id)); + // The current UI always selects an inspector; hide it only to exercise + // the requested closed-pane layout, without inventing a product control. + await page.locator('#globalTaskInspector').evaluate(el=>el.hidden=true); + await measure('closed-fixture'); + } + assert.deepEqual(errors,[]); + assert.equal(requests.length,6);requests.forEach(r=>assert.equal(r.limit,'50')); + fs.writeFileSync(path.join(output,'receipt.json'),JSON.stringify({scope:'isolated real-module browser fixture; no live authentication or deployment',baseline,requests,measurements,passed:true},null,2)); + console.log(JSON.stringify(measurements,null,2)); + } finally {await browser.close();await new Promise(resolve=>server.close(resolve));} +})().catch(e=>{console.error(e);process.exitCode=1;}); diff --git a/tests/frontend/taskPanelLayout.test.js b/tests/frontend/taskPanelLayout.test.js index 3f20c6e4f..1e5dadb8d 100644 --- a/tests/frontend/taskPanelLayout.test.js +++ b/tests/frontend/taskPanelLayout.test.js @@ -156,7 +156,8 @@ describe('global task board layout', () => { const mainRule = cssRule('.global-task-main'); expect(boardViewportRule).toContain('overflow: auto'); - expect(boardViewportRule).toContain('max-height: clamp('); + expect(boardViewportRule).toContain('flex: 1 1 0'); + expect(boardViewportRule).toContain('min-height: max(420px, 68dvh)'); expect(boardViewportRule).toContain('overscroll-behavior: contain'); expect(listViewportRule).toContain('overflow: visible'); expect(mainRule).not.toContain('overflow-x: auto'); From 8386a840d5b848b270810e3f7ecbe00c228a0027 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 17:58:24 -0400 Subject: [PATCH 2/2] test(tasks): accept explicit baseline revision for browser comparison --- tests/browser/taskBoardPagination.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/browser/taskBoardPagination.cjs b/tests/browser/taskBoardPagination.cjs index 5e7dfcd17..1a72dce98 100644 --- a/tests/browser/taskBoardPagination.cjs +++ b/tests/browser/taskBoardPagination.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const assert = require('node:assert/strict'); const root = path.resolve('src/frontend/web/von_interface/static'); const output = path.resolve(process.argv[2] || '.run/task-board-pagination'); -const baseline = process.argv.includes('--baseline'); +const baseline = process.argv.find(arg => arg.startsWith('--baseline='))?.slice('--baseline='.length); const task = i => ({ task_concept_id: `#V#fixture_task_${i}`, title: `Research task ${i}`, description: 'Review the research evidence and record the outcome.', status: 'pending', priority: 'medium' }); const requests = []; const stubs = { @@ -35,7 +35,7 @@ const server = http.createServer((req,res) => { if(!file.startsWith(root + '/') || !fs.existsSync(file)) {res.statusCode=404;res.end();return;} res.setHeader('Content-Type',file.endsWith('.css')?'text/css':'text/javascript'); let source=fs.readFileSync(file,'utf8'); - if(baseline && url.pathname === '/styles.css') source=require('node:child_process').execFileSync('git',['show','HEAD:src/frontend/web/von_interface/static/styles.css'],{encoding:'utf8'}); + if(baseline && url.pathname === '/styles.css') source=require('node:child_process').execFileSync('git',['show',`${baseline}:src/frontend/web/von_interface/static/styles.css`],{encoding:'utf8'}); res.end(source); } });