From e73a7775e052ca0028e12740b1d2c304bc3ca29e Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 19:00:11 -0400 Subject: [PATCH 1/2] Fix footer reflow and composer clearance during browser zoom (JVNAUTOSCI-2753) --- .../web/von_interface/static/styles.css | 18 ++- tests/browser/footerNativeZoom.cjs | 115 ++++++++++++++++++ 2 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 tests/browser/footerNativeZoom.cjs diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 295c7375..7464df20 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -62,8 +62,8 @@ body { color: #333; padding-top: 0; /* Dynamic layout offsets the fixed header and tab container. */ - padding-bottom: 60px; - /* ADDED: Space for the fixed footer */ + padding-bottom: var(--von-fixed-footer-clearance, 64px); + /* Reserve the measured fixed footer height as its contents reflow. */ } body.von-auth-pending, @@ -6580,6 +6580,8 @@ footer { display: flex; align-items: center; justify-content: space-between; + flex-wrap: wrap; + gap: 8px 16px; padding: 8px 16px; background-color: rgba(255, 255, 255, 0.95); border-top: 1px solid #e5e7eb; @@ -6946,6 +6948,8 @@ body[data-active-tab="settingsTab"] .tab-content-area { } .language-indicator { + flex-shrink: 0; + white-space: nowrap; margin: 0; font-size: 0.75rem; color: #6b7280; @@ -7258,7 +7262,7 @@ body[data-active-tab="settingsTab"] .tab-content-area { overflow-y: visible; /* Allow body scrolling so content is never clipped */ padding: 0 20px; - padding-bottom: 64px; + padding-bottom: var(--von-fixed-footer-clearance, 64px); /* Reserve space so content isn't hidden behind footer */ box-sizing: border-box; width: 100% !important; @@ -11623,7 +11627,7 @@ body[data-active-tab="settingsTab"] .tab-content-area { .chat-composer { position: sticky; - bottom: 42px; + bottom: calc(var(--von-fixed-footer-clearance, 64px) + 8px); z-index: 30; display: grid; gap: 8px; @@ -12021,12 +12025,6 @@ body[data-active-tab="settingsTab"] .tab-content-area { background: #ffe4e6; } -@media (max-height: 700px) { - .chat-composer { - bottom: 36px; - } -} - /* When inside the tab-content-area, make the tab content flex to propagate height */ .tab-content-area .tab-content.active>.content-wrapper { min-height: calc(100vh - 60px - 80px); diff --git a/tests/browser/footerNativeZoom.cjs b/tests/browser/footerNativeZoom.cjs new file mode 100644 index 00000000..88be5cef --- /dev/null +++ b/tests/browser/footerNativeZoom.cjs @@ -0,0 +1,115 @@ +// Local read-only fixture acceptance using real X keyboard browser zoom. +// DISPLAY must name an isolated test display; XDOTOOL and LD_LIBRARY_PATH may +// point to operator-prepared tools. No model generation or saved settings writes. +// node tests/browser/footerNativeZoom.cjs URL SHA EVIDENCE_DIR [baseline] +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { chromium, expect } = require('@playwright/test'); +const [base, sha, output, mode] = process.argv.slice(2); +assert(['localhost', '127.0.0.1'].includes(new URL(base).hostname)); +assert(process.env.DISPLAY, 'Use an isolated operator-prepared X display'); +fs.mkdirSync(output, { recursive: true }); +const key = (...args) => execFileSync(process.env.XDOTOOL || 'xdotool', args, { encoding: 'utf8' }); +(async () => { + const browser = await chromium.launch({ headless: false, args: ['--window-size=1600,1000', '--disable-gpu'] }); + const receipt = { browser: browser.version(), sha, base, mode: mode || 'candidate', + method: 'Native X Ctrl+equal / Ctrl+0, fixed 1600x1000 outer window; viewport:null. Narrow case is viewport resizing only.', cases: [] }; + try { + const context = await browser.newContext({ viewport: null }); + const page = await context.newPage(); + page.setDefaultTimeout(25000); + await page.route('**/von/generate', route => route.abort()); + const health = await (await page.request.get(`${base}/health`)).json(); + assert.equal(health.agent_test_instance, true); + assert.equal(health.version_details.git_commit, sha); + receipt.health = health.version_details; + await page.goto(`${base}/von/`); + await page.locator('#vonBrowserTestLoginButton').click(); + await expect(page.locator('#promptInput')).toBeVisible(); + await expect(page.locator('.footer-info .footer-segment').first()).toBeVisible(); + await page.waitForTimeout(4000); + const auth = await page.evaluate(async () => (await fetch('/von/api/auth/status')).json()); + assert.equal(auth.authenticated, true); + assert.equal(auth.auth_provider, 'browser_test_fixture'); + receipt.actor = auth.user_concept_id || auth.user?.concept_id || auth.user?.user_concept_id; + await page.locator('#promptInput').fill('Unsent footer zoom acceptance draft'); + await page.bringToFront(); + const windows = key('search', '--onlyvisible', '--class', 'Chromium').trim().split('\n'); + assert.equal(windows.length, 1, 'The isolated display must have exactly one Chromium window'); + key('windowfocus', '--sync', windows[0]); + const cdp = await context.newCDPSession(page); + async function capture(name) { + // Playwright surface capture can produce blank images at native zoom. + const shot = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: false }); + fs.writeFileSync(path.join(output, `${name}.png`), Buffer.from(shot.data, 'base64')); + } + async function check(name, ratio, narrow = false) { + await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(ratio); + await page.waitForTimeout(800); + await expect(page.locator('#promptInput')).toHaveValue('Unsent footer zoom acceptance draft'); + const mobile = await page.locator('.mobile-footer-details > summary').isVisible(); + if (mobile) await page.locator('.mobile-footer-details > summary').click(); + const m = await page.evaluate(() => { + const rect = node => { + const r = node.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; + }; + const footer = document.querySelector('.footer-container'); + const language = document.querySelector('#languageIndicator'); + const range = document.createRange(); range.selectNodeContents(language); + return { dpr: devicePixelRatio, innerWidth, innerHeight, outerWidth, outerHeight, + scrollWidth: document.documentElement.scrollWidth, + clearance: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--von-fixed-footer-clearance')), + footer: rect(footer), composer: rect(document.querySelector('.chat-composer')), + languageLines: range.getClientRects().length, language: language.textContent, + groups: [...footer.children].filter(e => e.getBoundingClientRect().height).map(e => ({ id: e.id, ...rect(e) })), + build: document.querySelector('#serverBuildValue').textContent }; + }); + receipt.cases.push({ name, mobile, ...m }); + await capture(name); + if (mode !== 'baseline') { + assert(m.scrollWidth <= m.innerWidth + 1, `${name}: document overflow`); + assert.equal(m.languageLines, 1, `${name}: split language label`); + for (const g of m.groups) { + assert(g.x >= m.footer.x - 1 && g.right <= m.footer.right + 1, `${name}: group clipped`); + } + if (!mobile) { + assert(m.clearance >= m.footer.height, `${name}: insufficient reserved height`); + assert(m.composer.bottom <= m.footer.y, `${name}: composer overlaps footer`); + } else assert.equal(m.clearance, 0); + } + if (!narrow) assert.equal(m.outerWidth, 1600); + // Open existing controls without changing a model or invoking it. + const cost = page.locator('.conversation-runtime-cost-button[data-cost-scope="conversation"]'); + await cost.click(); await expect(cost).toHaveAttribute('aria-expanded', 'true'); await cost.click(); + if (mobile) { + await page.locator('.mobile-footer-details > summary').focus(); await page.keyboard.press('Escape'); + await expect(page.locator('.footer-container')).toBeHidden(); + } + await page.locator('.footer-org-menu-trigger').click(); + await expect(page.locator('.footer-org-options')).toBeVisible(); + await page.locator('.footer-org-menu-trigger').click(); + await page.locator('#sendButton').click({ trial: true }); + fs.writeFileSync(path.join(output, 'receipt.json'), JSON.stringify(receipt, null, 2)); + } + for (const [name, keys, ratio] of [ + ['100', ['ctrl+0'], 1], ['125', ['ctrl+equal', 'ctrl+equal'], 1.25], + ['150', ['ctrl+equal'], 1.5], ['200', ['ctrl+equal', 'ctrl+equal'], 2], ['reset100', ['ctrl+0'], 1] + ]) { + for (const k of keys) key('key', '--clearmodifiers', k); + await check(name, ratio); + } + await page.setViewportSize({ width: 360, height: 640 }); + await check('narrow360', 1, true); + await page.locator('.mobile-footer-details > summary').click(); + await page.locator('.conversation-model-controls button').click(); + await page.locator('.mobile-footer-details > summary').click(); + await expect(page.frameLocator('#settingsFrame').locator('#premium-model-settings')).toBeVisible(); + receipt.modelSettingsReachable = true; + receipt.completed = true; + fs.writeFileSync(path.join(output, 'receipt.json'), JSON.stringify(receipt, null, 2)); + console.log(`${receipt.mode}: ${receipt.cases.length} cases passed; evidence ${output}`); + } finally { await browser.close(); } +})().catch(e => { console.error(e); process.exitCode = 1; }); From 35ea4658b57a5be86ac35b322305bef63156aaf7 Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 19:00:38 -0400 Subject: [PATCH 2/2] Allow fixture login to finish in native zoom acceptance --- tests/browser/footerNativeZoom.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/browser/footerNativeZoom.cjs b/tests/browser/footerNativeZoom.cjs index 88be5cef..439f935e 100644 --- a/tests/browser/footerNativeZoom.cjs +++ b/tests/browser/footerNativeZoom.cjs @@ -6,7 +6,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); -const { chromium, expect } = require('@playwright/test'); +const { chromium, expect: baseExpect } = require('@playwright/test'); +const expect = baseExpect.configure({ timeout: 25000 }); const [base, sha, output, mode] = process.argv.slice(2); assert(['localhost', '127.0.0.1'].includes(new URL(base).hostname)); assert(process.env.DISPLAY, 'Use an isolated operator-prepared X display');