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() {