From 9238830700cd8d1fe7ecd045be7379eb6a6577d1 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 17:08:39 -0400 Subject: [PATCH 1/4] Adapt mobile shell and keyboard clearance (JVNAUTOSCI-1192) --- .../static/js/components/dynamicLayout.js | 113 ++++++++++++++++ .../web/von_interface/static/js/main.js | 103 +-------------- .../web/von_interface/static/styles.css | 97 ++++++++++++++ .../templates/von_interface.html | 2 +- tests/browser/responsiveShell.cjs | 125 ++++++++++++++++++ tests/frontend/dynamicLayout.test.js | 48 +++++++ 6 files changed, 385 insertions(+), 103 deletions(-) create mode 100644 src/frontend/web/von_interface/static/js/components/dynamicLayout.js create mode 100644 tests/browser/responsiveShell.cjs create mode 100644 tests/frontend/dynamicLayout.test.js diff --git a/src/frontend/web/von_interface/static/js/components/dynamicLayout.js b/src/frontend/web/von_interface/static/js/components/dynamicLayout.js new file mode 100644 index 000000000..83f1fe31b --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/dynamicLayout.js @@ -0,0 +1,113 @@ +/** + * Dynamically calculate and set positions for tabs and content based on actual header height + * JVNAUTOSCI-550: Replace hard-coded CSS positions with JavaScript calculation + */ +export function setupDynamicLayout() { + function updateLayout() { + const header = document.getElementById('globalHeader'); + const tabContainer = document.querySelector('.tab-container'); + const contentAreas = document.querySelectorAll('.tab-content'); + const tabContentArea = document.querySelector('.tab-content-area'); + + if (!header || !tabContainer) { + console.warn('Dynamic layout: Required elements not found'); + return; + } + + // Get actual header height including search box + const headerHeight = header.getBoundingClientRect().height; + tabContainer.style.top = `${headerHeight}px`; + const measuredTabHeight = Math.max( + 44, + Math.ceil(tabContainer.getBoundingClientRect().height || tabContainer.scrollHeight || 44) + ); + const tabHeight = measuredTabHeight; + const contentTop = headerHeight + tabHeight + 8; // 8px margin + const narrow = window.matchMedia('(max-width: 800px), (max-width: 1024px) and (pointer: coarse)').matches; + const footer = document.querySelector('.footer-container'); + const footerSpace = narrow ? Math.ceil(footer?.getBoundingClientRect().height || 64) + 8 : Math.max(64, Math.ceil(footer?.getBoundingClientRect().height || 0)); + // At normal zoom the visual viewport excludes the on-screen keyboard. + // Pinch zoom must not resize the application or discard a draft. + const viewport = window.visualViewport; + const viewportHeight = narrow && viewport?.scale === 1 ? viewport.height : window.innerHeight; + const keyboardInset = narrow && viewport?.scale === 1 + ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0; + document.documentElement.style.setProperty('--von-viewport-height', `${viewportHeight}px`); + document.documentElement.style.setProperty('--von-keyboard-inset', `${keyboardInset}px`); + + document.documentElement.style.setProperty('--von-fixed-shell-height', `${contentTop}px`); + document.documentElement.style.setProperty('--von-fixed-footer-clearance', `${footerSpace}px`); + + // Position main tab content area below tabs + if (tabContentArea) { + tabContentArea.style.marginTop = `${contentTop}px`; + // Also update the minimum height calculation to account for dynamic header + const totalTopSpace = contentTop + 20; // content margin + padding + tabContentArea.style.minHeight = `calc(100vh - ${totalTopSpace + footerSpace}px)`; + tabContentArea.style.setProperty('--von-tab-content-available-height', `calc(100vh - ${contentTop + footerSpace}px)`); + tabContentArea.style.removeProperty('height'); + tabContentArea.style.overflowY = 'visible'; + } + + // Position standalone content areas below tabs. Content already inside the + // tab-content-area inherits that offset from the parent and must not stack it. + contentAreas.forEach(area => { + if (tabContentArea && tabContentArea.contains(area)) { + area.style.marginTop = '0px'; + } else { + area.style.marginTop = `${contentTop}px`; + } + }); + } + + // Initial layout + updateLayout(); + + // Re-calculate on window resize + window.addEventListener('resize', updateLayout); + window.visualViewport?.addEventListener('resize', updateLayout); + window.visualViewport?.addEventListener('scroll', updateLayout); + + // Re-calculate when search UI changes (in case it affects header height) + const searchInput = document.getElementById('vontologySearchInput'); + // Re-select header here (local inside updateLayout previously) to avoid scope errors + const headerEl = document.getElementById('globalHeader'); + const tabContainer = document.querySelector('.tab-container'); + if (searchInput && headerEl && !headerEl._dynamicLayoutObserved) { + try { + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(updateLayout); + }); + resizeObserver.observe(headerEl); + headerEl._dynamicLayoutObserved = true; // flag to prevent duplicate observers + } catch (e) { + console.warn('Dynamic layout: ResizeObserver setup failed', e); + } + } + + if (tabContainer && !tabContainer._dynamicLayoutObserved) { + try { + const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(updateLayout); + }); + resizeObserver.observe(tabContainer); + tabContainer._dynamicLayoutObserved = true; + } catch (e) { + console.warn('Dynamic layout: Tab container ResizeObserver setup failed', e); + } + } + + const footer = document.querySelector('.footer-container'); + if (footer && !footer._dynamicLayoutObserved && typeof ResizeObserver === 'function') { + new ResizeObserver(() => requestAnimationFrame(updateLayout)).observe(footer); + footer._dynamicLayoutObserved = true; + } + + if (!document._dynamicTabStripLayoutListenerBound) { + document.addEventListener('von:tab-strip-layout-changed', () => { + requestAnimationFrame(updateLayout); + }); + document._dynamicTabStripLayoutListenerBound = true; + } +} + diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index 68db51f99..00d96bfc7 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -1,3 +1,4 @@ +import { setupDynamicLayout } from './components/dynamicLayout.js'; import { initializeDomElements, initializeInfoPopup, loadAndDisplayGlobalModelInFooter, setFooterServerReachability } from './domUtils.js'; import { closeDynamicConceptTab, @@ -619,109 +620,7 @@ document.addEventListener('orgSwitched', (event) => { } }); -/** - * Dynamically calculate and set positions for tabs and content based on actual header height - * JVNAUTOSCI-550: Replace hard-coded CSS positions with JavaScript calculation - */ -function setupDynamicLayout() { - function updateLayout() { - const header = document.getElementById('globalHeader'); - const tabContainer = document.querySelector('.tab-container'); - const contentAreas = document.querySelectorAll('.tab-content'); - const tabContentArea = document.querySelector('.tab-content-area'); - - if (!header || !tabContainer) { - console.warn('Dynamic layout: Required elements not found'); - return; - } - - // Get actual header height including search box - const headerHeight = header.getBoundingClientRect().height; - tabContainer.style.top = `${headerHeight}px`; - const measuredTabHeight = Math.max( - 44, - Math.ceil(tabContainer.getBoundingClientRect().height || tabContainer.scrollHeight || 44) - ); - const tabHeight = measuredTabHeight; - const contentTop = headerHeight + tabHeight + 8; // 8px margin - const footerSpace = Math.max(64, Math.ceil(document.querySelector('.footer-container')?.getBoundingClientRect().height || 0)); - - document.documentElement.style.setProperty('--von-fixed-shell-height', `${contentTop}px`); - document.documentElement.style.setProperty('--von-fixed-footer-clearance', `${footerSpace}px`); - - console.log(`Dynamic layout: Header ${headerHeight}px, positioning tabs at ${headerHeight}px, content at ${contentTop}px`); - - // Position main tab content area below tabs - if (tabContentArea) { - tabContentArea.style.marginTop = `${contentTop}px`; - // Also update the minimum height calculation to account for dynamic header - const totalTopSpace = contentTop + 20; // content margin + padding - tabContentArea.style.minHeight = `calc(100vh - ${totalTopSpace + footerSpace}px)`; - tabContentArea.style.setProperty('--von-tab-content-available-height', `calc(100vh - ${contentTop + footerSpace}px)`); - tabContentArea.style.removeProperty('height'); - tabContentArea.style.overflowY = 'visible'; - } - - // Position standalone content areas below tabs. Content already inside the - // tab-content-area inherits that offset from the parent and must not stack it. - contentAreas.forEach(area => { - if (tabContentArea && tabContentArea.contains(area)) { - area.style.marginTop = '0px'; - } else { - area.style.marginTop = `${contentTop}px`; - } - }); - } - - // Initial layout - updateLayout(); - - // Re-calculate on window resize - window.addEventListener('resize', updateLayout); - - // Re-calculate when search UI changes (in case it affects header height) - const searchInput = document.getElementById('vontologySearchInput'); - // Re-select header here (local inside updateLayout previously) to avoid scope errors - const headerEl = document.getElementById('globalHeader'); - const tabContainer = document.querySelector('.tab-container'); - if (searchInput && headerEl && !headerEl._dynamicLayoutObserved) { - try { - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(updateLayout); - }); - resizeObserver.observe(headerEl); - headerEl._dynamicLayoutObserved = true; // flag to prevent duplicate observers - } catch (e) { - console.warn('Dynamic layout: ResizeObserver setup failed', e); - } - } - if (tabContainer && !tabContainer._dynamicLayoutObserved) { - try { - const resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(updateLayout); - }); - resizeObserver.observe(tabContainer); - tabContainer._dynamicLayoutObserved = true; - } catch (e) { - console.warn('Dynamic layout: Tab container ResizeObserver setup failed', e); - } - } - - const footerEl = document.querySelector('.footer-container'); - if (footerEl && !footerEl._dynamicLayoutObserved && typeof ResizeObserver === 'function') { - const footerObserver = new ResizeObserver(() => requestAnimationFrame(updateLayout)); - footerObserver.observe(footerEl); - footerEl._dynamicLayoutObserved = true; - } - - if (!document._dynamicTabStripLayoutListenerBound) { - document.addEventListener('von:tab-strip-layout-changed', () => { - requestAnimationFrame(updateLayout); - }); - document._dynamicTabStripLayoutListenerBound = true; - } -} function startHealthPolling() { const localIpSpan = document.getElementById('serverLocalIpValue'); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 147969a94..825969894 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18268,3 +18268,100 @@ body.settings-body { margin-block: 0.3rem; } .chat-steering-feedback .btn-mini { flex-shrink: 0; } +/* Phone and folded-device shell. Keep the existing desktop layout and DOM; + resizing must never recreate a conversation or its draft. */ +@media (max-width: 800px), (max-width: 1024px) and (pointer: coarse) { + .von-authentication-gate { + min-height: 100dvh; + padding: max(20px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) + max(20px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); + } + + .global-header { + padding-top: env(safe-area-inset-top); + } + + .global-header-shell { + gap: 8px; + padding: 6px max(8px, env(safe-area-inset-right)) 6px max(8px, env(safe-area-inset-left)); + } + + .global-header .main-container { flex-basis: 25%; } + .global-header #vontologySearchInput { font-size: 16px; min-height: 44px; } + .tab-container { padding-left: max(8px, env(safe-area-inset-left)); } + .tab-button { min-height: 44px; box-sizing: border-box; } + + .tab-content-area { + padding: 0 max(8px, env(safe-area-inset-right)) var(--von-fixed-footer-clearance, 64px) + max(8px, env(safe-area-inset-left)); + } + + #chatTab > .content-wrapper { width: 100%; } + .chat-session-tabs-row { flex-wrap: wrap; gap: 4px; } + .chat-session-tabs { flex-basis: 100%; } + .chat-session-tab { min-height: 44px; } + .chat-session-history-summary { max-width: 100%; } + .chat-session-history-controls { flex-wrap: wrap; } + .chat-session-history-controls button { min-height: 44px; } + .chat-conversation-masthead { grid-template-columns: minmax(0, 1fr); gap: 4px; } + .chat-header-controls { + flex-wrap: nowrap; + justify-content: flex-start; + overflow-x: auto; + padding-bottom: 4px; + } + .chat-conversation-masthead .chat-export-btn { flex-shrink: 0; min-height: 44px; min-width: 44px; } + .scrollable-field { overflow-wrap: anywhere; } + .scrollable-field pre { max-width: 100%; overflow-x: auto; } + .scrollable-field img { max-width: 100%; height: auto; } + + .chat-composer { + bottom: calc(var(--von-fixed-footer-clearance, 64px) + var(--von-keyboard-inset, 0px)); + max-height: calc(var(--von-viewport-height, 100dvh) - 60px); + overflow-y: auto; + } + #promptInput { font-size: 16px; } + .chat-composer-more-actions > summary { min-height: 44px; display: flex; align-items: center; } + + .footer-container { + /* Let the fixed organisation menu use the viewport, not a filter-created containing block. */ + backdrop-filter: none; + -webkit-backdrop-filter: none; + bottom: var(--von-keyboard-inset, 0px); + padding: 4px max(8px, env(safe-area-inset-right)) max(4px, env(safe-area-inset-bottom)) + max(8px, env(safe-area-inset-left)); + gap: 8px; + } + .footer-info { + min-width: 0; + white-space: nowrap; + overflow-x: auto; + } + .footer-container > :not(#modelInfoFooter) { display: none; } + .footer-info button, .footer-org-menu-trigger { min-height: 44px; } + .footer-org-menu { + position: fixed; + left: max(8px, env(safe-area-inset-left)); + right: max(8px, env(safe-area-inset-right)); + bottom: calc(var(--von-fixed-footer-clearance, 64px) + var(--von-keyboard-inset, 0px)); + width: auto; + max-width: none; + max-height: calc(var(--von-viewport-height, 100dvh) - var(--von-fixed-footer-clearance, 64px) - 16px); + overflow-y: auto; + } + + .tab-content.dynamic-concept-tab { width: 100% !important; min-width: 0; } + .concept-header { flex-wrap: wrap; } + .dynamic-concept-tab button, .dynamic-concept-tab input, .dynamic-concept-tab select { + min-height: 44px; + } + .dynamic-concept-tab input, .dynamic-concept-tab select, .dynamic-concept-tab textarea { + font-size: 16px; max-width: 100%; box-sizing: border-box; + } + .dynamic-concept-tab .round-icon-button { min-width: 44px; } + .settings-container { padding: 12px; } + .settings-container select, .settings-container input[type="text"], .settings-container textarea { + max-width: 100%; min-width: 0; box-sizing: border-box; font-size: 16px; + } + .settings-container button, .settings-container select { min-height: 44px; } +} diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index b03adc851..eb3d6cbb3 100644 --- a/src/frontend/web/von_interface/templates/von_interface.html +++ b/src/frontend/web/von_interface/templates/von_interface.html @@ -3,7 +3,7 @@ - + Von diff --git a/tests/browser/responsiveShell.cjs b/tests/browser/responsiveShell.cjs new file mode 100644 index 000000000..f39cbd36f --- /dev/null +++ b/tests/browser/responsiveShell.cjs @@ -0,0 +1,125 @@ +// Run: node tests/browser/responsiveShell.cjs [evidence-directory] +// Production templates, CSS, layout and tray controller with synthetic content. +// This checks rendering and draft retention, not authentication or live sending. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2]; +if (evidence) fs.mkdirSync(evidence, { recursive: true }); +function template(name) { + return fs.readFileSync(path.join(root, 'templates', name), 'utf8') + .replace(/{% include '([^']+)' %}/g, (_, child) => template(child)) + .replace(/]*>[\s\S]*?<\/script>/g, '') + .replace(/{{ url_for\('static', filename='([^']+)'\) }}/g, '/static/$1') + .replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, ''); +} +const server = http.createServer((req, res) => { + if (req.url === '/' || req.url === '/settings') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + const name = req.url === '/settings' ? 'settings_tab.html' : 'von_interface.html'; + res.end(template(name)); + return; + } + const file = path.resolve(root, `.${req.url}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); +}); +async function noOverflow(page) { + const size = await page.evaluate(() => ({ viewport: innerWidth, document: document.documentElement.scrollWidth })); + assert(size.document <= size.viewport + 1, `global overflow: ${JSON.stringify(size)}`); +} +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({ headless: true }); + try { + for (const profile of [ + { name: 'pixel-8', width: 412, height: 915, touch: true }, + { name: 'folded', width: 360, height: 740, touch: true }, + { name: 'tablet', width: 768, height: 1024, touch: true }, + { name: 'unfolded', width: 840, height: 900, touch: true }, + { name: 'landscape', width: 915, height: 412, touch: true }, + { name: 'desktop', width: 1440, height: 900 } + ]) { + const page = await browser.newPage({ viewport: profile, hasTouch: !!profile.touch, isMobile: !!profile.touch }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await noOverflow(page); + await expect(page.locator('#vonGoogleLoginButton')).toBeVisible(); + await page.evaluate(async () => { + // Synthetic signed-in presentation; no credentials or identity bypass. + document.body.classList.remove('von-auth-pending'); + document.querySelector('#vonAuthenticationGate').hidden = true; + document.querySelector('#vonAuthenticatedApp').hidden = false; + document.querySelector('#headerOrgName').textContent = 'Research organisation with a long name'; + document.querySelector('#modelInfoFooter').innerHTML = `User: + + + Model: configured model`; + document.querySelector('#chatSessionTabs').innerHTML = Array.from({ length: 12 }, (_, i) => + ``).join(''); + document.querySelector('#chatSessionHistoryControls').innerHTML = ''; + document.querySelector('#scrollableField').innerHTML = Array.from({ length: 25 }, (_, i) => + `

Turn ${i + 1}: A readable saved research conversation with provenance and next steps.

`).join(''); + const workspace = document.querySelector('#conversationWorkspace'); + const { createConversationTray } = await import('/static/js/components/conversationTray.js'); + const tray = createConversationTray(workspace); + const refresh = () => { + workspace.dataset.effectiveTabsLayout = innerWidth <= 900 ? 'horizontal' : 'vertical'; + tray.refresh({ width: 260, collapsed: false, expandOnHover: true }); + }; + refresh(); + window.addEventListener('resize', refresh); + const { setupDynamicLayout } = await import('/static/js/components/dynamicLayout.js'); + setupDynamicLayout(); + }); + await page.waitForTimeout(100); + await noOverflow(page); + const input = page.locator('#promptInput'); + await input.fill('Draft survives resizing and folding.'); + await page.locator('#sendButton').click({ trial: true }); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await page.locator('.footer-org-menu-trigger').click(); + await page.getByRole('button', { name: 'Personal', exact: true }).click({ trial: true }); + const menu = await page.locator('.footer-org-menu').boundingBox(); + assert(menu.x >= 0 && menu.x + menu.width <= profile.width + 1 && menu.y >= 0, 'organisation menu fits'); + await page.locator('.footer-org-menu-trigger').click(); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}.png`), fullPage: false }); + if (profile.name === 'pixel-8') { + await page.setViewportSize({ width: 412, height: 430 }); + await input.focus(); + await page.locator('#sendButton').click({ trial: true }); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await noOverflow(page); + const send = await page.locator('#sendButton').boundingBox(); + const footer = await page.locator('.footer-container').boundingBox(); + assert(send.y >= 0 && send.y + send.height <= footer.y, 'keyboard-sized viewport keeps send above footer'); + if (evidence) await page.screenshot({ path: path.join(evidence, 'pixel-8-keyboard.png') }); + await page.setViewportSize({ width: 840, height: 900 }); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + await page.setViewportSize({ width: 412, height: 915 }); + await expect(input).toHaveValue('Draft survives resizing and folding.'); + } + await page.evaluate(markup => { + document.querySelectorAll('.tab-content.active').forEach(tab => tab.classList.remove('active')); + const concept = document.createElement('div'); + concept.className = 'tab-content dynamic-concept-tab active'; + concept.innerHTML = markup; + document.querySelector('.tab-content-area').append(concept); + document.querySelector('#conceptFormTitleText').textContent = 'Research concept with a longer descriptive name'; + }, template('concept_tab.html')); + await noOverflow(page); + await page.locator('#discussConceptButton').click({ trial: true }); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-concept.png`) }); + await page.goto(`http://127.0.0.1:${server.address().port}/settings`); + await noOverflow(page); + await expect(page.locator('#preferredLanguageSelect')).toBeVisible(); + if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-settings.png`) }); + console.log(JSON.stringify({ profile: profile.name, passed: true, source: 'synthetic production-template fixture' })); + await page.close(); + } + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => server.close()); diff --git a/tests/frontend/dynamicLayout.test.js b/tests/frontend/dynamicLayout.test.js new file mode 100644 index 000000000..1b5b473f1 --- /dev/null +++ b/tests/frontend/dynamicLayout.test.js @@ -0,0 +1,48 @@ +import { setupDynamicLayout } from '../../src/frontend/web/von_interface/static/js/components/dynamicLayout.js'; + +// One installed shell, resized repeatedly like a browser session. +test('tracks footer and visual viewport changes without replacing the draft or resizing for pinch zoom', () => { + document.body.innerHTML = `
+
+
`; + let narrow = true; + window.matchMedia = jest.fn(() => ({ matches: narrow })); + const viewport = new EventTarget(); + Object.assign(viewport, { height: 915, offsetTop: 0, scale: 1 }); + Object.defineProperty(window, 'visualViewport', { configurable: true, value: viewport }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 915 }); + const observed = []; + global.ResizeObserver = class { + constructor(callback) { this.callback = callback; } + observe(target) { observed.push({ target, callback: this.callback }); } + }; + window.requestAnimationFrame = callback => callback(); + const footer = document.querySelector('footer'); + let footerHeight = 52; + footer.getBoundingClientRect = () => ({ height: footerHeight }); + document.querySelector('header').getBoundingClientRect = () => ({ height: 90 }); + document.querySelector('.tab-container').getBoundingClientRect = () => ({ height: 48 }); + const input = document.querySelector('#promptInput'); + setupDynamicLayout(); + const value = name => document.documentElement.style.getPropertyValue(name); + expect(value('--von-fixed-footer-clearance')).toBe('60px'); + viewport.height = 430; + viewport.dispatchEvent(new Event('resize')); + expect(value('--von-keyboard-inset')).toBe('485px'); + expect(value('--von-viewport-height')).toBe('430px'); + viewport.offsetTop = 20; + viewport.dispatchEvent(new Event('scroll')); + expect(value('--von-keyboard-inset')).toBe('465px'); + footerHeight = 76; + observed.find(item => item.target === footer).callback(); + expect(value('--von-fixed-footer-clearance')).toBe('84px'); + viewport.scale = 2; + viewport.dispatchEvent(new Event('resize')); + expect(value('--von-keyboard-inset')).toBe('0px'); + expect(value('--von-viewport-height')).toBe('915px'); + narrow = false; + window.dispatchEvent(new Event('resize')); + expect(value('--von-fixed-footer-clearance')).toBe('64px'); + expect(document.querySelector('#promptInput')).toBe(input); + expect(input.value).toBe('Keep this draft'); +}); From f043dc97ae633fe29aa7c7831f7d61c5f23e167d Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 19:56:32 -0400 Subject: [PATCH 2/4] Preserve updated desktop baseline and mobile footer information --- .../static/js/components/dynamicLayout.js | 21 ++++++++++++++ .../web/von_interface/static/js/domUtils.js | 1 + .../web/von_interface/static/styles.css | 28 ++++++++++++++++--- tests/browser/responsiveShell.cjs | 20 +++++++++---- tests/frontend/dynamicLayout.test.js | 8 ++++-- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/frontend/web/von_interface/static/js/components/dynamicLayout.js b/src/frontend/web/von_interface/static/js/components/dynamicLayout.js index 83f1fe31b..92e91f480 100644 --- a/src/frontend/web/von_interface/static/js/components/dynamicLayout.js +++ b/src/frontend/web/von_interface/static/js/components/dynamicLayout.js @@ -3,6 +3,19 @@ * JVNAUTOSCI-550: Replace hard-coded CSS positions with JavaScript calculation */ export function setupDynamicLayout() { + // Reuse the actual diagnostic controls so their handlers and live values survive. + const footerContainer = document.querySelector('.footer-container'); + const secondaryFooterItems = footerContainer + ? Array.from(footerContainer.children).filter(item => item.id !== 'modelInfoFooter') : []; + const footerDetails = document.createElement('details'); + footerDetails.className = 'mobile-footer-details'; + const summary = document.createElement('summary'); + summary.textContent = 'Info'; + summary.setAttribute('aria-label', 'Footer information and diagnostics'); + const detailsPanel = document.createElement('div'); + detailsPanel.className = 'mobile-footer-details-panel'; + footerDetails.append(summary, detailsPanel); + function updateLayout() { const header = document.getElementById('globalHeader'); const tabContainer = document.querySelector('.tab-container'); @@ -25,6 +38,14 @@ export function setupDynamicLayout() { const contentTop = headerHeight + tabHeight + 8; // 8px margin const narrow = window.matchMedia('(max-width: 800px), (max-width: 1024px) and (pointer: coarse)').matches; const footer = document.querySelector('.footer-container'); + if (footer && narrow && !footerDetails.isConnected) { + detailsPanel.append(...secondaryFooterItems); + footer.append(footerDetails); + } else if (!narrow && footerDetails.isConnected) { + footer.append(...secondaryFooterItems); + footerDetails.remove(); + footerDetails.open = false; + } const footerSpace = narrow ? Math.ceil(footer?.getBoundingClientRect().height || 64) + 8 : Math.max(64, Math.ceil(footer?.getBoundingClientRect().height || 0)); // At normal zoom the visual viewport excludes the on-screen keyboard. // Pinch zoom must not resize the application or discard a draft. diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 52d27b9df..e19ef7ce0 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -1790,6 +1790,7 @@ export async function setModelInfoFooterText() { userInfo?.conceptId || null, userInfo?.name || null, ); + userSegment.classList.add('footer-user-segment'); footerIdentityTargets.push({ button: userSegment.querySelector('.concept-footer-button'), identityKind: 'User', diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 825969894..d9c99ee6b 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18333,11 +18333,31 @@ body.settings-body { gap: 8px; } .footer-info { - min-width: 0; - white-space: nowrap; - overflow-x: auto; + min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; + white-space: normal; + } + .footer-info > .footer-separator { display: none; } + .footer-user-segment { order: -2; } + .footer-org-switcher { order: -1; } + .footer-user-segment, .footer-org-switcher { max-width: 100%; } + .footer-user-segment .concept-footer-button, + .footer-org-current-button { max-width: min(150px, calc((100vw - 140px) / 2)); overflow: hidden; text-overflow: ellipsis; } + .footer-info .conversation-model-controls { flex-basis: 100%; } + + .mobile-footer-details { flex-shrink: 0; } + .mobile-footer-details > summary { + min-height: 44px; display: flex; align-items: center; cursor: pointer; + } + .mobile-footer-details-panel { + position: fixed; + left: 8px; right: 8px; + bottom: calc(var(--von-fixed-footer-clearance, 64px) + var(--von-keyboard-inset, 0px)); + max-height: calc(var(--von-viewport-height, 100dvh) - var(--von-fixed-footer-clearance, 64px) - 16px); + overflow: auto; padding: 12px; + background: var(--background-color, white); color: var(--text-color, #222); + border: 1px solid #aaa; border-radius: 8px; } - .footer-container > :not(#modelInfoFooter) { display: none; } + .mobile-footer-details-panel p { display: block; white-space: normal; } .footer-info button, .footer-org-menu-trigger { min-height: 44px; } .footer-org-menu { position: fixed; diff --git a/tests/browser/responsiveShell.cjs b/tests/browser/responsiveShell.cjs index f39cbd36f..0ed0006ff 100644 --- a/tests/browser/responsiveShell.cjs +++ b/tests/browser/responsiveShell.cjs @@ -5,6 +5,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const baseline = process.env.VON_LAYOUT_BASELINE; const { chromium, expect } = require('@playwright/test'); const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); const evidence = process.argv[2]; @@ -26,6 +28,14 @@ const server = http.createServer((req, res) => { const file = path.resolve(root, `.${req.url}`); if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + if (baseline && req.url === '/static/styles.css') { + return res.end(execFileSync('git', ['show', `${baseline}:src/frontend/web/von_interface/static/styles.css`])); + } + if (baseline && req.url === '/static/js/components/dynamicLayout.js') { + const main = execFileSync('git', ['show', `${baseline}:src/frontend/web/von_interface/static/js/main.js`], { encoding: 'utf8' }); + const start = main.indexOf('function setupDynamicLayout()'); + return res.end('export ' + main.slice(start, main.indexOf('\n}', start) + 2)); + } res.end(fs.readFileSync(file)); }); async function noOverflow(page) { @@ -43,7 +53,7 @@ async function noOverflow(page) { { name: 'unfolded', width: 840, height: 900, touch: true }, { name: 'landscape', width: 915, height: 412, touch: true }, { name: 'desktop', width: 1440, height: 900 } - ]) { + ].filter(profile => !baseline || profile.name === 'desktop')) { const page = await browser.newPage({ viewport: profile, hasTouch: !!profile.touch, isMobile: !!profile.touch }); await page.goto(`http://127.0.0.1:${server.address().port}`); await noOverflow(page); @@ -87,7 +97,7 @@ async function noOverflow(page) { const menu = await page.locator('.footer-org-menu').boundingBox(); assert(menu.x >= 0 && menu.x + menu.width <= profile.width + 1 && menu.y >= 0, 'organisation menu fits'); await page.locator('.footer-org-menu-trigger').click(); - if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}.png`), fullPage: false }); + if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}.png`), fullPage: false }); if (profile.name === 'pixel-8') { await page.setViewportSize({ width: 412, height: 430 }); await input.focus(); @@ -97,7 +107,7 @@ async function noOverflow(page) { const send = await page.locator('#sendButton').boundingBox(); const footer = await page.locator('.footer-container').boundingBox(); assert(send.y >= 0 && send.y + send.height <= footer.y, 'keyboard-sized viewport keeps send above footer'); - if (evidence) await page.screenshot({ path: path.join(evidence, 'pixel-8-keyboard.png') }); + if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, 'pixel-8-keyboard.png') }); await page.setViewportSize({ width: 840, height: 900 }); await expect(input).toHaveValue('Draft survives resizing and folding.'); await page.setViewportSize({ width: 412, height: 915 }); @@ -113,11 +123,11 @@ async function noOverflow(page) { }, template('concept_tab.html')); await noOverflow(page); await page.locator('#discussConceptButton').click({ trial: true }); - if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-concept.png`) }); + if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}-concept.png`) }); await page.goto(`http://127.0.0.1:${server.address().port}/settings`); await noOverflow(page); await expect(page.locator('#preferredLanguageSelect')).toBeVisible(); - if (evidence) await page.screenshot({ path: path.join(evidence, `${profile.name}-settings.png`) }); + if (evidence) await page.screenshot({ animations: 'disabled', path: path.join(evidence, `${profile.name}-settings.png`) }); console.log(JSON.stringify({ profile: profile.name, passed: true, source: 'synthetic production-template fixture' })); await page.close(); } diff --git a/tests/frontend/dynamicLayout.test.js b/tests/frontend/dynamicLayout.test.js index 1b5b473f1..33cbb0ad9 100644 --- a/tests/frontend/dynamicLayout.test.js +++ b/tests/frontend/dynamicLayout.test.js @@ -4,7 +4,7 @@ import { setupDynamicLayout } from '../../src/frontend/web/von_interface/static/ test('tracks footer and visual viewport changes without replacing the draft or resizing for pinch zoom', () => { document.body.innerHTML = `
-
`; +

Uptime

`; let narrow = true; window.matchMedia = jest.fn(() => ({ matches: narrow })); const viewport = new EventTarget(); @@ -26,6 +26,8 @@ test('tracks footer and visual viewport changes without replacing the draft or r setupDynamicLayout(); const value = name => document.documentElement.style.getPropertyValue(name); expect(value('--von-fixed-footer-clearance')).toBe('60px'); + const diagnostics = document.querySelector('#serverUptimeFooter'); + expect(diagnostics.parentElement.className).toBe('mobile-footer-details-panel'); viewport.height = 430; viewport.dispatchEvent(new Event('resize')); expect(value('--von-keyboard-inset')).toBe('485px'); @@ -42,7 +44,9 @@ test('tracks footer and visual viewport changes without replacing the draft or r expect(value('--von-viewport-height')).toBe('915px'); narrow = false; window.dispatchEvent(new Event('resize')); - expect(value('--von-fixed-footer-clearance')).toBe('64px'); + expect(value('--von-fixed-footer-clearance')).toBe('76px'); + expect(diagnostics.parentElement).toBe(footer); + expect(document.querySelector('.mobile-footer-details')).toBeNull(); expect(document.querySelector('#promptInput')).toBe(input); expect(input.value).toBe('Keep this draft'); }); From 5230ba9610e1003319d5218e40bf0c55629a9e27 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 19:59:10 -0400 Subject: [PATCH 3/4] Keep unified message sending above the mobile footer --- .../web/von_interface/static/styles.css | 16 +++++ tests/browser/mobileAuthenticated.cjs | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/browser/mobileAuthenticated.cjs diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index d9c99ee6b..97a823b68 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18315,6 +18315,22 @@ body.settings-body { .scrollable-field pre { max-width: 100%; overflow-x: auto; } .scrollable-field img { max-width: 100%; height: auto; } + .unified-message-content .message-compose-area { + position: fixed; + left: max(8px, env(safe-area-inset-left)); + right: max(8px, env(safe-area-inset-right)); + bottom: calc(var(--von-fixed-footer-clearance, 64px) + var(--von-keyboard-inset, 0px)); + z-index: 100; padding: 8px; gap: 4px; + max-height: calc(var(--von-viewport-height, 100dvh) - 60px); + overflow-y: auto; + } + .unified-message-content .message-compose-row { flex-direction: row; align-items: flex-end; } + .unified-message-content .message-input { + min-width: 0; min-height: 44px; height: 44px; box-sizing: border-box; font-size: 16px; + } + .unified-message-content .send-message-btn { width: auto; min-height: 44px; } + .unified-message-content .message-view-content { padding-bottom: 130px; } + .chat-composer { bottom: calc(var(--von-fixed-footer-clearance, 64px) + var(--von-keyboard-inset, 0px)); max-height: calc(var(--von-viewport-height, 100dvh) - 60px); diff --git a/tests/browser/mobileAuthenticated.cjs b/tests/browser/mobileAuthenticated.cjs new file mode 100644 index 000000000..ed179c80d --- /dev/null +++ b/tests/browser/mobileAuthenticated.cjs @@ -0,0 +1,64 @@ +// Local AgentTest server only. Sends one labelled message to the dedicated +// Workflow Reviewer fixture; blocks generation so no paid model is invoked. +// Usage: PLAYWRIGHT_BROWSERS_PATH=... node tests/browser/mobileAuthenticated.cjs URL SHA DIR +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const [base, commit, evidence] = process.argv.slice(2); +assert(['localhost', '127.0.0.1'].includes(new URL(base).hostname)); +fs.mkdirSync(evidence, { recursive: true }); +(async () => { + const browser = await chromium.launch(); + try { + const page = await browser.newPage({ viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true }); + page.setDefaultTimeout(20000); + await page.route('**/von/generate', route => route.abort()); + const health = await (await page.request.get(`${base}/health`)).json(); + assert.equal(health.version_details.git_commit, commit); + await page.goto(`${base}/von/`); + await page.locator('#vonBrowserTestLoginButton').click(); + await expect(page.locator('#promptInput')).toBeVisible(); + 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'); + await page.locator('.footer-org-menu-trigger').click(); + await page.locator('.footer-org-option').filter({ hasText: 'University' }).click(); + const reviewer = () => page.locator('.message-conversation-row').filter({ hasText: 'Workflow Reviewer' }); + await reviewer().click(); + const draft = `Mobile layout acceptance ${Date.now()}: dedicated fixture message; no action requested.`; + const input = page.locator('#messageInput'); + await input.fill(draft); + for (const [name, width, height] of [ + ['pixel-8', 412, 915], ['keyboard', 412, 430], ['folded', 360, 740], + ['tablet', 768, 1024], ['unfolded', 840, 900], ['desktop', 1440, 900] + ]) { + await page.setViewportSize({ width, height }); + await expect(input).toHaveValue(draft); + await page.locator('#sendMessageBtn').click({ trial: true }); + const size = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth })); + assert(size.document <= size.viewport + 1, `${name}: no global overflow`); + await page.screenshot({ path: path.join(evidence, `${name}.png`), animations: 'disabled' }); + } + await page.setViewportSize({ width: 412, height: 915 }); + const sent = page.waitForResponse(response => response.url().endsWith('/api/messages/') && response.request().method() === 'POST'); + await page.locator('#sendMessageBtn').click(); + assert([200, 201].includes((await sent).status())); + await expect(input).toHaveValue(''); + await expect(page.locator('#messageViewContent')).toContainText(draft); + await page.reload(); + await reviewer().click(); + await expect(page.locator('#messageViewContent')).toContainText(draft); + await page.locator('.mobile-footer-details > summary').click(); + await expect(page.locator('#serverUptimeFooter')).toBeVisible(); + await page.locator('.mobile-footer-details > summary').click(); + await page.locator('.footer-org-menu-trigger').click(); + await expect(page.locator('.footer-org-option').filter({ hasText: 'Personal' })).toBeVisible(); + await page.locator('.footer-org-menu-trigger').click(); + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.screenshot({ path: path.join(evidence, 'settings.png'), animations: 'disabled' }); + const result = { commit, authenticated: true, authProvider: auth.auth_provider, fixtureRecipient: 'Workflow Reviewer', sentAndReadBack: true, profiles: 6, generationBlocked: true }; + fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify(result)); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); From afd06a0ea1caa0a2228c0febe596b41673b10856 Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 20:02:34 -0400 Subject: [PATCH 4/4] Stabilise authenticated mobile acceptance navigation --- tests/browser/mobileAuthenticated.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/browser/mobileAuthenticated.cjs b/tests/browser/mobileAuthenticated.cjs index ed179c80d..030fef82d 100644 --- a/tests/browser/mobileAuthenticated.cjs +++ b/tests/browser/mobileAuthenticated.cjs @@ -22,6 +22,8 @@ fs.mkdirSync(evidence, { recursive: true }); 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'); + // Initial health/model refresh replaces the footer once after login. + await page.waitForTimeout(3000); await page.locator('.footer-org-menu-trigger').click(); await page.locator('.footer-org-option').filter({ hasText: 'University' }).click(); const reviewer = () => page.locator('.message-conversation-row').filter({ hasText: 'Workflow Reviewer' }); @@ -55,7 +57,8 @@ fs.mkdirSync(evidence, { recursive: true }); await page.locator('.footer-org-menu-trigger').click(); await expect(page.locator('.footer-org-option').filter({ hasText: 'Personal' })).toBeVisible(); await page.locator('.footer-org-menu-trigger').click(); - await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.locator('[data-tab="settingsTab"]').click(); + await expect(page.frameLocator('#settingsFrame').locator('#preferredLanguageSelect')).toBeVisible(); await page.screenshot({ path: path.join(evidence, 'settings.png'), animations: 'disabled' }); const result = { commit, authenticated: true, authProvider: auth.auth_provider, fixtureRecipient: 'Workflow Reviewer', sentAndReadBack: true, profiles: 6, generationBlocked: true }; fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2));