diff --git a/dashboard/css/expert.css b/dashboard/css/expert.css new file mode 100644 index 0000000..962dc9c --- /dev/null +++ b/dashboard/css/expert.css @@ -0,0 +1,250 @@ +/** + * Spaxel Dashboard - Expert Mode Styles + * + * Expert mode 3D scene layout with responsive canvas sizing. + * Handles iOS Safari visual viewport quirks and bottom navigation spacing. + */ + +/* ===== Scene Container (3D Canvas) ===== */ +#scene-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + /* Full viewport height by default */ + height: 100vh; + /* Fallback for browsers that don't support viewport units */ + height: 100%; + z-index: 1; +} + +/* Canvas height adjustment when simple mode navigation is visible */ +body.simple-mode #scene-container { + /* Simple mode has 56px bottom nav, subtract from viewport height */ + height: calc(100vh - 56px); +} + +/* iOS Safari-specific handling for visual viewport */ +@supports (-webkit-touch-callout: none) { + /* iOS Safari with visual viewport API */ + #scene-container { + /* Use dynamic viewport height on iOS (handles address bar) */ + height: 100dvh; + } + + body.simple-mode #scene-container { + /* Subtract bottom navigation bar height */ + height: calc(100dvh - 56px); + } +} + +/* iOS Safari orientation change handling */ +/* Prevent iOS Safari from scaling content on orientation change */ +@supports (-webkit-touch-callout: none) { + body { + /* Prevent automatic text size adjustment on orientation change */ + -webkit-text-size-adjust: 100%; + } +} + +/* Ensure canvas doesn't overflow during transitions */ +#scene-container canvas { + display: block; + max-width: 100%; + max-height: 100%; +} + +/* Handle visual viewport resize events (iOS Safari keyboard, address bar) */ +.visual-viewport-resizing #scene-container { + /* Prevent layout shift during visual viewport changes */ + pointer-events: none; +} + +/* ===== Status Bar ===== */ +#status-bar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 40px; + background: rgba(0, 0, 0, 0.9); + z-index: 100; + display: flex; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Adjust scene container when status bar is visible */ +#status-bar ~ #scene-container { + top: 40px; + height: calc(100vh - 40px); +} + +/* Simple mode navigation bar adjustment */ +body.simple-mode .simple-quick-actions { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 56px; + z-index: 100; +} + +/* ===== Mode Toggle Bar ===== */ +.mode-toggle-bar { + position: fixed; + top: 40px; + left: 0; + right: 0; + height: 44px; + z-index: 90; +} + +/* Adjust scene container when mode toggle bar is visible */ +.mode-toggle-bar ~ #scene-container, +body:not(.simple-mode) .mode-toggle-bar ~ #scene-container { + top: 84px; /* 40px status bar + 44px mode toggle bar */ + height: calc(100vh - 84px); +} + +/* Both status bar and mode toggle bar present */ +#status-bar .mode-toggle-bar ~ #scene-container { + top: 84px; + height: calc(100vh - 84px); +} + +/* ===== Alert Banner ===== */ +#alert-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 5000; +} + +/* Adjust scene container when alert banner is visible */ +#alert-banner.visible ~ #scene-container { + top: 60px; /* Approximate alert banner height */ + height: calc(100vh - 60px); +} + +/* ===== Orientation-specific adjustments ===== */ + +/* Landscape orientation */ +@media screen and (orientation: landscape) { + #scene-container { + /* In landscape, ensure full height is used */ + height: 100vh; + height: 100dvh; + } + + body.simple-mode #scene-container { + height: calc(100vh - 56px); + height: calc(100dvh - 56px); + } + + /* Adjust for landscape status bar */ + #status-bar ~ #scene-container { + height: calc(100vh - 40px); + height: calc(100dvh - 40px); + } + + /* Adjust for landscape mode toggle bar */ + .mode-toggle-bar ~ #scene-container { + height: calc(100vh - 44px); + height: calc(100dvh - 44px); + } +} + +/* Portrait orientation */ +@media screen and (orientation: portrait) { + #scene-container { + height: 100vh; + height: 100dvh; + } + + body.simple-mode #scene-container { + height: calc(100vh - 56px); + height: calc(100dvh - 56px); + } +} + +/* ===== Responsive breakpoints ===== */ + +/* Small mobile devices */ +@media screen and (max-width: 600px) { + #scene-container { + height: 100vh; + height: 100dvh; + } + + body.simple-mode #scene-container { + height: calc(100vh - 56px); + height: calc(100dvh - 56px); + } + + /* On small mobile, status bar and mode toggle may stack */ + #status-bar ~ .mode-toggle-bar ~ #scene-container { + top: 84px; + height: calc(100vh - 84px); + height: calc(100dvh - 84px); + } +} + +/* Tablets and larger */ +@media screen and (min-width: 601px) { + #scene-container { + height: 100vh; + height: 100dvh; + } +} + +/* ===== Safe area insets for notched devices ===== */ +@supports (padding: max(0px)) { + #scene-container { + /* Use safe-area-inset for devices with notches (iPhone X+) */ + padding-top: env(safe-area-inset-top); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + } + + /* Adjust height calculation to account for safe areas */ + #scene-container { + height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } + + body.simple-mode #scene-container { + /* Simple mode: also subtract bottom nav height */ + height: calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 56px); + height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 56px); + } +} + +/* ===== Prevent overscroll/bounce on iOS ===== */ +@supports (-webkit-touch-callout: none) { + #scene-container { + /* Prevent rubber-band scrolling effect */ + -webkit-overflow-scrolling: touch; + overscroll-behavior: none; + } +} + +/* ===== Loading state ===== */ +#scene-container.loading { + opacity: 0.5; + pointer-events: none; + transition: opacity 0.3s ease; +} + +/* ===== Hide scene container in simple mode ===== */ +body.simple-mode #scene-container { + display: none; +} + +/* ===== Expert mode specific styles ===== */ +body:not(.simple-mode) #scene-container { + display: block; +} diff --git a/dashboard/index.html b/dashboard/index.html index dd98607..66fb7d6 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -17,6 +17,7 @@ + diff --git a/dashboard/js/app.js b/dashboard/js/app.js index d4250a9..2f9e69a 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -154,6 +154,20 @@ // Handle window resize window.addEventListener('resize', onWindowResize); + // Handle orientation change (mobile devices) + window.addEventListener('orientationchange', onOrientationChange); + + // Handle visual viewport resize (iOS Safari specific) + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', function() { + // Throttle visual viewport resize events + if (orientationChangeTimeout) { + clearTimeout(orientationChangeTimeout); + } + orientationChangeTimeout = setTimeout(onWindowResize, 50); + }); + } + // Clock for animation mixer delta clock = new THREE.Clock(); @@ -178,10 +192,75 @@ console.log('[Spaxel] Scene initialized'); } + /** + * Get the current viewport dimensions, preferring visualViewport API on iOS Safari. + * Falls back to window.innerWidth/Height for browsers that don't support visualViewport. + * @returns {Object} {width: number, height: number} Viewport dimensions in pixels + */ + function getViewportDimensions() { + // Use visualViewport API on iOS Safari (handles address bar, bottom nav) + if (window.visualViewport && window.visualViewport.width > 0) { + return { + width: window.visualViewport.width, + height: window.visualViewport.height + }; + } + // Fallback to standard window dimensions + return { + width: window.innerWidth, + height: window.innerHeight + }; + } + + /** + * Handle window resize events with iOS Safari visual viewport support. + * Updates renderer size, camera aspect ratio, and projection matrix. + */ function onWindowResize() { - camera.aspect = window.innerWidth / window.innerHeight; + const dims = getViewportDimensions(); + const width = dims.width; + const height = dims.height; + + // Update camera aspect ratio + camera.aspect = width / height; camera.updateProjectionMatrix(); - renderer.setSize(window.innerWidth, window.innerHeight); + + // Update renderer size + renderer.setSize(width, height); + + // Update pixel ratio in case it changed (e.g., moving between displays) + renderer.setPixelRatio(window.devicePixelRatio || 1); + } + + /** + * Handle orientation change events. + * Debounced to avoid multiple rapid calls during orientation transition. + */ + var orientationChangeTimeout = null; + + function onOrientationChange() { + // Clear any pending resize + if (orientationChangeTimeout) { + clearTimeout(orientationChangeTimeout); + } + + // Debounce resize to allow browser to complete orientation transition + // iOS Safari visual viewport may take a moment to stabilize + orientationChangeTimeout = setTimeout(function() { + // Force a full resize after orientation change completes + onWindowResize(); + + // Additional visual viewport check for iOS Safari + if (window.visualViewport) { + // iOS Safari: wait for visual viewport to stabilize + // The resize event will fire again once it does + window.visualViewport.addEventListener('resize', function onViewportResize() { + onWindowResize(); + // Remove listener after first callback + window.visualViewport.removeEventListener('resize', onViewportResize); + }, { once: true }); + } + }, 100); } function animate() {