diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 85963eb0..aadb91e6 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -1009,6 +1009,7 @@ function ensureFooterDbLoadingBadge(footer) { const badge = document.createElement('span'); badge.className = 'db-conn-badge loading'; + badge.tabIndex = 0; badge.style.marginLeft = '12px'; badge.innerHTML = 'πŸ•“ Mongo: loading... RTT … DB …'; setKeptNativeTitle(badge, 'Loading database status...'); @@ -1031,6 +1032,7 @@ function attachFooterDbBadge(footer, dbInfo, initialServerRoundTripMs = null) { const badge = document.createElement('span'); const baseClass = classification === 'local' ? 'local' : (classification === 'atlas' ? 'atlas' : 'remote'); badge.className = `db-conn-badge ${baseClass}`; + badge.tabIndex = 0; let labelIcon = '🌐'; if (classification === 'local') labelIcon = '🏠'; else if (classification === 'atlas') labelIcon = 'πŸ—ΊοΈ'; @@ -1138,6 +1140,13 @@ function attachFooterDbBadge(footer, dbInfo, initialServerRoundTripMs = null) { rttSpan.classList.remove('fatal', 'warn', 'slow'); dbSpan.classList.remove('fatal', 'warn', 'slow'); + // Only known, fast measurements may collapse. Outages and missing values + // remain visible; reuse the existing warning thresholds below. + rttSpan.classList.toggle('footer-quiet-metric', state !== 'server_down_unknown' + && Number.isFinite(serverRoundTripMs) && serverRoundTripMs <= 250); + dbSpan.classList.toggle('footer-quiet-metric', state !== 'server_down_unknown' + && state !== 'fatal_atlas' && Number.isFinite(mongoPingLatencyMs) + && mongoPingLatencyMs <= 100); if (state === 'fatal_atlas') { dbSpan.textContent = 'DB offline'; diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 5c2e86e5..d05c46df 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18690,3 +18690,27 @@ html[data-footer-diagnostics="false"] .footer-container #serverPidFooter { displ html[data-footer-uptime="false"][data-footer-runtimecost="false"][data-footer-build="false"] #serverUptimeFooter { display: none; } .footer-container [hidden] { display: none !important; } + +/* Compact status details expand in the row, preserving its scroll behaviour. + Keep clipped text available to assistive technology even before expansion. */ +.footer-container .db-conn-badge:not(:hover):not(:focus-within) .footer-quiet-metric, +.footer-container :is(.conversation-model-controls, .footer-user-segment, .footer-org-switcher):not(:hover):not(:focus-within) > .footer-label-inline { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.footer-container :is(.conversation-model-controls, .footer-user-segment, .footer-org-switcher):not(:hover):not(:focus-within) .concept-footer-button { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.footer-container .db-conn-badge:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} diff --git a/tests/browser/footerCompactCartouches.cjs b/tests/browser/footerCompactCartouches.cjs new file mode 100644 index 00000000..399a4fd5 --- /dev/null +++ b/tests/browser/footerCompactCartouches.cjs @@ -0,0 +1,141 @@ +// Candidate-source layout acceptance. API fixtures make latency states repeatable; +// no authentication, database, model request or live service is used. +const { chromium, expect } = require('@playwright/test'); +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const { execFileSync } = require('node:child_process'); +const nativeZoom = process.env.NATIVE_FOOTER_ZOOM === '1'; +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const template = fs.readFileSync(path.join(root, 'templates/von_interface.html'), 'utf8'); +const footer = template.slice(template.indexOf(' '), template.indexOf(' ')).replace(/{%.*?%}/g, ''); +const html = `${footer}`; +const server = http.createServer((req, res) => { + if (req.url === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(html); } + if (!req.url.startsWith('/static/') || req.url.includes('..')) { res.writeHead(404); return res.end(); } + const file = path.join(root, req.url.split('?')[0]); + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css'); + try { res.end(fs.readFileSync(file)); } catch { res.writeHead(404); res.end(); } +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({headless:!nativeZoom, args:['--window-size=1600,1000']}); + const page = await browser.newPage({viewport:nativeZoom ? null : {width:1600,height:800}}); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + let delay = 5, db = 0.8; + await page.route('**/api/**', async route => { + const url = new URL(route.request().url()).pathname; + let body = {}; + if (/\/settings\/?$/.test(url)) body = {resolved_llm:{model:'gpt-6-astra',provider:'openai'}}; + if (url.includes('/llm/info')) body = {model:'gpt-6-astra',provider:'openai',status:'ready',details:{}}; + if (url.includes('/auth/status')) body = {authenticated:true,email:'footer.researcher.with.long.name@example.test'}; + if (url.includes('capability-index/status')) body = {ready:true,status:'ready'}; + if (url.includes('my_organisations')) body = {organisations:[{concept_id:'#V#footer_org',name:'Footer Fixture Organisation',role:'member'}]}; + if (url.includes('/db/info')) { + await new Promise(resolve => setTimeout(resolve, delay)); + body = {classification:'local',ping_ok:true,mongo_ping_latency_ms:db}; + } + await route.fulfill({json:body}); + }); + const output = process.argv[2] || '.run/footer-compact-cartouches'; + fs.mkdirSync(output,{recursive:true}); + const evidence = []; + const clipped = locator => locator.evaluate(el => getComputedStyle(el).clipPath === 'inset(50%)'); + try { + for (const scenario of [ + {name:'normal',delay:5,db:0.8,rttQuiet:true,dbQuiet:true}, + {name:'slow-rtt',delay:304,db:0.8,rttQuiet:false,dbQuiet:true}, + {name:'slow-db',delay:5,db:501,rttQuiet:true,dbQuiet:false}, + {name:'partial',delay:5,db:null,rttQuiet:true,dbQuiet:false}, + ]) { + delay = scenario.delay; db = scenario.db; + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForFunction(() => window.fixtureReady); + await expect(page.locator('.conversation-model-controls')).toContainText('gpt-6-astra'); + const badge = page.locator('.db-conn-badge'); + const rtt = badge.locator('.server-rtt-latency'); + const ping = badge.locator('.db-latency'); + await expect(badge).not.toHaveClass(/loading/); + await page.mouse.move(0,0); + await expect.poll(() => clipped(rtt), {message:scenario.name}).toBe(scenario.rttQuiet); + await expect.poll(() => clipped(ping)).toBe(scenario.dbQuiet); + const compact = (await badge.boundingBox()).width; + await badge.hover(); + expect(await clipped(rtt)).toBe(false); + expect(await clipped(ping)).toBe(false); + const expanded = (await badge.boundingBox()).width; + expect(expanded).toBeGreaterThan(compact); + await page.mouse.move(0,0); + // Reach the informational badge through actual sequential keyboard navigation. + for (let i=0; i<20 && !await badge.evaluate(el => el === document.activeElement); i++) await page.keyboard.press('Tab'); + expect(await badge.evaluate(el => el === document.activeElement)).toBe(true); + expect(await clipped(ping)).toBe(false); + await page.screenshot({path:path.join(output,`${scenario.name}-focus.png`)}); + evidence.push({scenario:scenario.name,compact,expanded,rtt:await rtt.textContent(),db:await ping.textContent()}); + await page.evaluate(() => window.setReachability(false)); + expect(await clipped(rtt)).toBe(false); + expect(await clipped(ping)).toBe(false); + await expect(ping).toHaveText('DB unknown'); + } + if (nativeZoom) { + if (!process.env.DISPLAY || !process.env.XDOTOOL) throw new Error('Native zoom requires an isolated display and xdotool'); + const key = (...args) => execFileSync(process.env.XDOTOOL,args,{encoding:'utf8'}); + await page.bringToFront(); + const windows = key('search','--onlyvisible','--class','Chromium').trim().split('\n'); + expect(windows).toHaveLength(1); + key('windowfocus','--sync',windows[0]); + key('key','ctrl+0'); + const cdp = await page.context().newCDPSession(page); + for (const [steps,ratio] of [[0,1],[2,1.25],[1,1.5],[2,2]]) { + for (let i=0;i page.evaluate(() => devicePixelRatio)).toBe(ratio); + await page.locator('.db-conn-badge').focus(); + expect(await clipped(page.locator('.db-latency'))).toBe(false); + const measurement = await page.locator('.footer-container').evaluate(el => ({zoom:devicePixelRatio,width:innerWidth,height:el.getBoundingClientRect().height,scrollWidth:el.scrollWidth})); + expect(measurement.height).toBeLessThan(80); + evidence.push(measurement); + const shot = await cdp.send('Page.captureScreenshot',{format:'png',fromSurface:false}); + fs.writeFileSync(path.join(output,`zoom-${ratio}.png`),Buffer.from(shot.data,'base64')); + } + key('key','ctrl+0'); + await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(1); + } + delay = 1500; db = 0.8; + await page.goto(`http://127.0.0.1:${server.address().port}`); + await expect(page.locator('.db-conn-badge.loading')).toBeVisible(); + expect(await clipped(page.locator('.db-latency'))).toBe(false); + await page.waitForFunction(() => window.fixtureReady); + for (const width of [1600,800,493,320]) { + await page.setViewportSize({width,height:800}); + for (const selector of ['.conversation-model-controls','.footer-user-segment','.footer-org-switcher']) { + const segment = page.locator(selector).first(); + await segment.locator(selector === '.footer-org-switcher' ? 'summary' : 'button:not(:disabled)').first().focus(); + expect(await clipped(segment.locator('.footer-label-inline').first()), `${width} ${selector}`).toBe(false); + const box = await segment.boundingBox(); + expect(box.height).toBeLessThan(60); + } + await page.locator('.db-conn-badge').focus(); + const measure = await page.locator('.footer-container').evaluate(el => { + const badge = el.querySelector('.db-conn-badge').getBoundingClientRect(); + const previous = el.querySelector('.footer-org-switcher').getBoundingClientRect(); + return {width:innerWidth,scrollWidth:el.scrollWidth,clientWidth:el.clientWidth,height:el.getBoundingClientRect().height,noOverlap:badge.x >= previous.right}; + }); + expect(measure.noOverlap).toBe(true); + expect(measure.height).toBeLessThan(80); + evidence.push(measure); + } + expect(errors).toEqual([]); + fs.writeFileSync(path.join(output,'measurements.json'),JSON.stringify({fixture:'candidate source; mocked API reads',nativeZoom,evidence},null,2)); + console.log(JSON.stringify(evidence)); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode=1; }); diff --git a/tests/frontend/footerDbBadgeOutageStates.test.js b/tests/frontend/footerDbBadgeOutageStates.test.js index 1ff9927d..c2cf9698 100644 --- a/tests/frontend/footerDbBadgeOutageStates.test.js +++ b/tests/frontend/footerDbBadgeOutageStates.test.js @@ -137,4 +137,32 @@ describe('footer DB badge outage state handling', () => { expect(serverRtt?.title).toContain('Browser ↔ Von server round trip'); expect(dbLatency?.title).toContain('measured inside the Von server'); }); + + test.each([ + [250, 100, true, true], + [251, 0.8, false, true], + [601, 101, false, false], + [50, 501, true, false], + [50, null, true, false], + ])('collapses only fast known measurements (RTT %s, DB %s)', async (rtt, db, quietRtt, quietDb) => { + let clock = 0; + jest.spyOn(performance, 'now').mockImplementation(() => clock); + installFetchMock(buildDbInfo({ mongoPingLatencyMs: db })); + const fetchMock = global.fetch; + global.fetch = jest.fn((url, options) => { + if (String(url).includes('/db/info')) clock += rtt; + return fetchMock(url, options); + }); + const { setModelInfoFooterText, setFooterServerReachability } = require(domUtilsPath); + setFooterServerReachability(true); + await setModelInfoFooterText(); + const badge = await waitForDbBadgeReady(); + expect(badge.tabIndex).toBe(0); + expect(badge.querySelector('.server-rtt-latency').classList.contains('footer-quiet-metric')).toBe(quietRtt); + expect(badge.querySelector('.db-latency').classList.contains('footer-quiet-metric')).toBe(quietDb); + setFooterServerReachability(false); + expect(badge.querySelectorAll('.footer-quiet-metric')).toHaveLength(0); + setFooterServerReachability(true); + expect(badge.querySelector('.db-latency').classList.contains('footer-quiet-metric')).toBe(quietDb); + }); });