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..92e91f480 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/dynamicLayout.js @@ -0,0 +1,134 @@ +/** + * 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() { + // 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'); + 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'); + 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. + 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/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/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..97a823b68 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18268,3 +18268,136 @@ 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; } + + .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); + 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; 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; + } + .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; + 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 @@
- +