feat: add expert mode CSS and improve mobile viewport handling
- Add expert.css for expert mode specific styles - Improve mobile orientation change handling with visualViewport API support - Add iOS Safari visual viewport resize handling for better mobile experience - Add getViewportDimensions() function preferring visualViewport API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
496faaf12d
commit
eea920de19
3 changed files with 332 additions and 2 deletions
250
dashboard/css/expert.css
Normal file
250
dashboard/css/expert.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
<link rel="stylesheet" href="css/floorplan.css">
|
||||
<link rel="stylesheet" href="css/explainability.css">
|
||||
<link rel="stylesheet" href="css/replay.css">
|
||||
<link rel="stylesheet" href="css/expert.css">
|
||||
<link rel="stylesheet" href="css/simple.css">
|
||||
<link rel="stylesheet" href="css/command-palette.css">
|
||||
<link rel="stylesheet" href="css/ambient.css">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue