fix(ui): respect iOS safe areas, screen height and landscape layout

This commit is contained in:
Gr33nbl00d
2026-10-19 19:43:53 +02:00
parent 88772b3278
commit b2613b2106
20 changed files with 243 additions and 95 deletions
+26 -1
View File
@@ -82,6 +82,28 @@
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */
--accent-warm: #d19a66;
/* Device safe areas (notch, Dynamic Island, home indicator) */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* Full-height sheets start clear of the status-bar blur; flush when there is no inset */
--sheet-top: calc(var(--safe-top) + min(var(--safe-top), 24px));
--app-height: 100dvh;
}
/* The on-screen keyboard covers the home indicator */
:root.keyboard-open { --safe-bottom: 0px; }
/* iOS standalone web apps report dvh (and the viewport) short by the status bar
height; lvh still matches the screen. Body becomes the containing block for
fixed elements so overlays reach the real bottom edge as well. Containment
stops body's background reaching the canvas, so html paints it instead. */
@media (display-mode: standalone) {
@supports (-webkit-touch-callout: none) {
:root { --app-height: 100lvh; }
html { height: var(--app-height); background-color: var(--bg); }
body { contain: layout; overflow: clip; }
}
}
:root.light {
@@ -121,9 +143,12 @@ body {
font-family: var(--font-family, 'Fira Code', monospace);
display: flex;
height: 100%;
height: 100dvh; /* dynamic viewport height — adapts when mobile keyboard opens */
height: var(--app-height);
padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left);
overflow: hidden;
}
/* iOS keeps the full height when the keyboard opens; size the shell to what is visible */
:root.keyboard-open body { height: var(--keyboard-viewport-height, var(--app-height)); }
/* Self-hosted Fira Code font */
@font-face { font-family: 'Fira Code'; font-weight: 300; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Light.woff2') format('woff2'); }
+53 -23
View File
@@ -562,8 +562,8 @@ body.bg-pattern-ascii-fireflies {
/* Fixed hamburger — always visible, toggles sidebar */
.hamburger-btn {
position: fixed;
top: 12px;
left: 9px;
top: calc(12px + var(--safe-top));
left: calc(9px + var(--safe-left));
right: auto;
z-index: 210;
width: 30px;
@@ -583,7 +583,7 @@ body.bg-pattern-ascii-fireflies {
}
body.hamburger-right .hamburger-btn {
left: auto;
right: 9px;
right: calc(9px + var(--safe-right));
}
.mobile-new-chat-btn {
display: none;
@@ -655,6 +655,25 @@ body.bg-pattern-ascii-fireflies {
border-right: none;
border-left: 1px solid var(--border);
}
/* Edge columns bleed into the safe-area strips instead of leaving bare background */
@media (min-width: 769px) {
.sidebar:not(.hidden) {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: var(--safe-bottom);
}
.sidebar:not(.hidden):not(.right-side) {
margin-left: calc(-1 * var(--safe-left));
padding-left: var(--safe-left);
}
.sidebar.right-side:not(.hidden) {
margin-right: calc(-1 * var(--safe-right));
padding-right: var(--safe-right);
}
.icon-rail {
margin-bottom: calc(-1 * var(--safe-bottom));
padding-bottom: calc(8px + var(--safe-bottom));
}
}
.icon-rail-divider {
width: 24px;
height: 1px;
@@ -4800,7 +4819,7 @@ body.bg-pattern-ascii-fireflies {
.toast {
position:fixed;
top: 16px; right: 16px;
top: calc(16px + var(--safe-top)); right: calc(16px + var(--safe-right));
left: auto; bottom: auto;
background:var(--panel); color:var(--fg);
border:1px solid color-mix(in srgb, var(--accent) 30%, transparent);
@@ -4984,8 +5003,8 @@ body.bg-pattern-ascii-fireflies {
.toast.toast-panel-offset.exiting { transform: translateX(-120%); }
@media (max-width: 768px) {
.toast {
top: 12px;
right: 12px;
top: calc(12px + var(--safe-top));
right: calc(12px + var(--safe-right));
max-width: calc(100vw - 24px);
/* Receive touches so the swipe-to-dismiss gesture works (desktop keeps
pointer-events:none so the toast never blocks clicks). Horizontal pan
@@ -5109,6 +5128,7 @@ body.bg-pattern-ascii-fireflies {
--sidebar-bg. */
background: var(--sidebar-bg, var(--panel)) !important;
backdrop-filter: none !important;
padding: var(--safe-top) 0 var(--safe-bottom) var(--safe-left);
}
.sidebar.hidden {
width: 80% !important;
@@ -5122,6 +5142,8 @@ body.bg-pattern-ascii-fireflies {
.sidebar.right-side {
left: auto; right: 0;
box-shadow: -4px 0 20px rgba(0,0,0,0.5);
padding-left: 0;
padding-right: var(--safe-right);
}
html.ody-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar {
@@ -5315,9 +5337,9 @@ body.bg-pattern-ascii-fireflies {
.hamburger-btn {
width: 44px;
height: 44px;
top: 6px;
top: calc(6px + var(--safe-top));
left: auto !important;
right: 4px !important;
right: calc(4px + var(--safe-right)) !important;
-webkit-tap-highlight-color: transparent;
}
.hamburger-btn:hover,
@@ -5330,8 +5352,8 @@ body.bg-pattern-ascii-fireflies {
.mobile-new-chat-btn {
display: flex;
position: fixed;
top: 12px;
left: 8px;
top: calc(12px + var(--safe-top));
left: calc(8px + var(--safe-left));
z-index: 210;
width: 32px;
height: 32px;
@@ -5400,10 +5422,6 @@ body.bg-pattern-ascii-fireflies {
padding: 6px !important;
border-radius: 12px !important;
}
/* Safe area padding for notched devices */
.chat-input-bar {
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
/* Mode toggle — larger touch targets */
.mode-toggle {
height: 34px;
@@ -5447,15 +5465,15 @@ body.bg-pattern-ascii-fireflies {
URL-bar show/hide. Order matters: later same-specificity rule
wins, so dvh must come after vh. */
max-height: 100vh !important;
max-height: 100dvh !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
height: 100vh;
height: 100dvh;
height: calc(var(--app-height) - var(--sheet-top));
border-radius: 14px 14px 0 0 !important;
border: none !important;
border-top: 1px solid var(--border) !important;
box-shadow: none !important;
padding: 6px !important;
padding-bottom: env(safe-area-inset-bottom, 6px) !important;
padding-bottom: var(--safe-bottom) !important;
margin: 0 !important;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
@@ -5536,7 +5554,7 @@ body.bg-pattern-ascii-fireflies {
(against the flex-sized grid) as a definite height, so grid/flex
children never filled. An explicit px height is unambiguous. */
margin: 0 !important;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px)) !important;
padding: 8px 10px calc(8px + var(--safe-bottom)) !important;
background: var(--bg) !important;
/* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on
the preview + <pre>; in a flex column an explicit-height item
@@ -5615,7 +5633,7 @@ body.bg-pattern-ascii-fireflies {
/* Small bottom padding now that the load-more no longer steals space —
the action bar sits low, just clearing the home-indicator safe area. */
#doclib-modal [data-doclib-panel="documents"] .doclib-card.doclib-card-expanded .doclib-card-preview {
padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px)) !important;
padding-bottom: calc(18px + var(--safe-bottom)) !important;
}
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview pre,
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .doclib-card-pdf-frame,
@@ -6127,6 +6145,7 @@ body.bg-pattern-ascii-fireflies {
z-index:250;
backdrop-filter:none;
pointer-events:none;
padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left);
}
/* Cookbook always sits above Gallery so the "Serve a model in
Cookbook…" flow (and any other "open Cookbook from inside another
@@ -6160,6 +6179,17 @@ body.bg-pattern-ascii-fireflies {
centered window glides instead of jumping when the nav toggles. */
transition: left 0.25s ease, width 0.25s ease;
}
/* A right-side nav reserves its width on the right instead */
body.hamburger-right :is(#calendar-modal, #gallery-modal, #tasks-modal, #memory-modal, #doclib-modal,
#compare-model-overlay, #research-overlay, #theme-modal, #settings-modal, #email-lib-modal) {
left: 0;
}
/* Windows never grow past the area between the nav and the safe areas */
:is(#calendar-modal, #gallery-modal, #tasks-modal, #memory-modal, #doclib-modal,
#compare-model-overlay, #research-overlay, #theme-modal, #settings-modal, #email-lib-modal)
> :is(.modal-content, .memory-modal-content, .settings-modal-content, .doclib-modal-content) {
max-width: calc(100vw - var(--safe-left) - var(--safe-right) - var(--icon-rail-w, 48px) - var(--sidebar-w, 0px));
}
}
.modal-content {
background:var(--panel);
@@ -6689,8 +6719,8 @@ body.bg-pattern-ascii-fireflies {
/* Agent indicator */
#agent-indicator {
position: fixed;
top: 20px;
right: 20px;
top: calc(20px + var(--safe-top));
right: calc(20px + var(--safe-right));
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
@@ -7016,7 +7046,7 @@ body.bg-pattern-ascii-fireflies {
#compare-model-overlay .modal-content {
display: flex;
flex-direction: column;
max-height: min(720px, calc(100dvh - 48px));
max-height: min(720px, calc(var(--app-height) - 48px));
overflow: hidden;
min-height: 180px;
}
@@ -7433,7 +7463,7 @@ body.bg-pattern-ascii-fireflies {
#recording-indicator {
position: fixed;
top: 10px;
top: calc(10px + var(--safe-top));
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
+11 -9
View File
@@ -172,9 +172,8 @@
position:fixed; height:100% = the layout viewport, whose bottom sits
UNDER Firefox Android's bottom URL bar — so flex-end parked the sheet
(and its footer / Start button) beneath the bar. dvh excludes the bar
so the footer lands above it. Extra safe-area pad for iOS home bar. */
height: 100dvh;
padding-bottom: env(safe-area-inset-bottom, 0px);
so the footer lands above it. */
height: var(--app-height);
}
/* Confirm dialog stays centered, not a bottom sheet */
#styled-confirm-overlay {
@@ -204,7 +203,7 @@
border-radius: 14px 14px 0 0;
border: none;
border-top: 1px solid var(--border);
padding: 6px 12px 12px;
padding: 6px 12px calc(12px + var(--safe-bottom));
animation: sheet-enter 0.2s ease-out forwards;
overflow-y: hidden !important;
}
@@ -252,12 +251,12 @@
#email-lib-modal .modal-content,
#doclib-modal .doclib-modal-content {
max-height: 90vh !important;
max-height: 90dvh !important;
max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important;
/* Reserve the iOS home-indicator / bottom-bar strip so an expanded
skill card's footer (Delete / Edit / Run) isn't hidden under it.
dvh already excludes the URL bar; this handles the safe area. */
padding-bottom: env(safe-area-inset-bottom, 0px) !important;
padding-bottom: var(--safe-bottom) !important;
}
/* Grab handle pill on the non-standard content classes too. Memory's
desktop rule defines a radial-glow ::before later in the file —
@@ -305,7 +304,7 @@
visible viewport on Chrome/Safari mobile (URL bar covered the
action buttons row). */
max-height: 90vh !important;
max-height: 90dvh !important;
max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important;
}
/* Anchor those modals to the top so the full height is usable */
@@ -314,8 +313,11 @@
#email-lib-modal,
#doclib-modal,
#gallery-modal,
#tasks-modal {
padding-top: 0 !important;
#tasks-modal,
#memory-modal,
#settings-modal {
padding-top: var(--sheet-top) !important;
padding-bottom: 0 !important;
align-items: stretch !important;
}
/* Deep Research already gets the swipe grab-handle pill from the
+3 -3
View File
@@ -410,8 +410,8 @@ button.hamburger {
/* Agent indicator */
#agent-indicator {
position: fixed;
top: 20px;
right: 20px;
top: calc(20px + var(--safe-top));
right: calc(20px + var(--safe-right));
background: var(--sidebar-bg, var(--panel));
color: var(--fg);
border: 1px solid var(--border);
@@ -894,7 +894,7 @@ button.hamburger {
/* Recording indicator */
#recording-indicator {
position: fixed;
top: 10px;
top: calc(10px + var(--safe-top));
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
+3 -1
View File
@@ -2666,11 +2666,13 @@ mark.doc-find-mark.current {
render it as a narrow side pane ("sidebar") on a phone. */
position: fixed !important;
inset: 0 !important;
top: 0 !important; right: 0 !important; bottom: 0 !important; left: 0 !important;
top: var(--sheet-top) !important; right: 0 !important; bottom: 0 !important; left: 0 !important;
height: auto !important;
max-width: 100% !important;
width: 100% !important;
flex: none !important;
z-index: 170;
padding-bottom: var(--safe-bottom);
/* Stroke the top edge so the rounded corners read as a curved sheet edge. */
border: 1px solid var(--border);
border-bottom: none;
+16 -10
View File
@@ -1181,8 +1181,8 @@ span[id$="-logo"] svg:not([width]) {
opacity: 0;
transform-origin: center center;
position: absolute;
right: calc(16px + env(safe-area-inset-right, 0px));
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
right: calc(16px + var(--safe-right));
bottom: calc(18px + var(--safe-bottom));
height: var(--fab-size);
min-width: var(--fab-size);
padding: 0 20px 0 18px;
@@ -1298,17 +1298,23 @@ span[id$="-logo"] svg:not([width]) {
instead of becoming a side panel. */
#doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content {
position: fixed !important;
top: 0 !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important;
bottom: 0 !important;
top: var(--safe-top) !important;
left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: var(--safe-right) !important;
bottom: var(--safe-bottom) !important;
width: auto !important;
max-width: none !important;
height: 100vh !important;
max-height: 100vh !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
}
@media (min-width: 769px) {
body.hamburger-right #doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content {
left: var(--safe-left) !important;
right: calc(var(--safe-right) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
}
}
#doclib-modal.doclib-fullscreen .modal-header,
#doclib-modal.doclib-fullscreen .lib-tabs,
#doclib-modal.modal-right-docked .modal-header,
@@ -1415,10 +1421,10 @@ span[id$="-logo"] svg:not([width]) {
panes reserve room with margins so left + right docks can be active at the
same time without skewing the entire body box. */
body.right-dock-active {
padding-right: 0;
padding-right: var(--safe-right);
}
body.left-dock-active {
padding-left: 0;
padding-left: var(--safe-left);
}
body.left-dock-active:not(.email-doc-split-active) .chat-container {
margin-left: var(--left-dock-w, 0px);
+25 -19
View File
@@ -447,13 +447,11 @@
edges and let the inner body absorb the remaining height. */
body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .modal-content {
position: fixed !important;
inset: 0 !important;
left: 0 !important;
top: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
inset: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left) !important;
width: auto !important;
max-width: none !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
display: flex !important;
@@ -472,17 +470,23 @@ body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .e
one of the two CSS vars is 0 at any given time, set by init.js). */
body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
position: fixed !important;
top: 0 !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important;
bottom: 0 !important;
top: var(--safe-top) !important;
left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: var(--safe-right) !important;
bottom: var(--safe-bottom) !important;
width: auto !important;
max-width: none !important;
height: 100vh !important;
max-height: 100vh !important;
height: auto !important;
max-height: none !important;
border-radius: 0 !important;
transform: none !important;
}
@media (min-width: 769px) {
body.hamburger-right:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
left: var(--safe-left) !important;
right: calc(var(--safe-right) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
}
}
/* Mobile: the /email route adds .email-lib-fullscreen, but the desktop full-
bleed rule above squares off the corners and offsets the email by the icon
rail. On phones the email should be the normal bottom-sheet — full width,
@@ -490,12 +494,14 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
so it wins on mobile. */
@media (max-width: 768px) {
body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
left: 0 !important;
right: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100dvh !important;
max-height: 100dvh !important;
top: var(--sheet-top) !important;
left: var(--safe-left) !important;
right: var(--safe-right) !important;
bottom: 0 !important;
width: auto !important;
max-width: none !important;
height: auto !important;
max-height: none !important;
border-radius: 14px 14px 0 0 !important;
border-top: 1px solid var(--border) !important;
}
+1 -1
View File
@@ -4502,7 +4502,7 @@ button .spinner-whirlpool {
left: 8px !important;
right: 8px !important;
top: auto !important;
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px) !important;
bottom: calc(var(--safe-bottom) + 8px) !important;
width: auto !important;
min-width: 0 !important;
max-width: calc(100vw - 16px) !important;
+4 -3
View File
@@ -4638,13 +4638,14 @@ body.notes-view .chat-container { flex: 1; min-width: 0; }
@media (max-width: 768px) {
body.notes-view .notes-pane {
position: fixed;
inset: 0;
inset: var(--sheet-top) 0 0;
max-width: 100%;
width: 100% !important;
/* Desktop sets height: min(80vh, 820px) which wins over inset:0's bottom:0
and leaves a gap at the bottom of the viewport. Force full height here. */
height: 100dvh !important;
max-height: 100dvh !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
padding-bottom: var(--safe-bottom);
z-index: 170;
/* Bottom-sheet treatment like the document editor: stroked, rounded top,
slides up from the bottom. */
+2 -2
View File
@@ -1439,7 +1439,7 @@ body.theme-frosted .modal {
/* Bottom-sheet: 90dvh tall (a touch smaller than full-screen) anchored to the
bottom, so there's a small gap at the top and it reads as a sheet. The body
still scrolls within the bounded height. */
#research-overlay { align-items: flex-end !important; justify-content: stretch !important; }
#research-overlay { align-items: flex-end !important; justify-content: stretch !important; padding-bottom: 0 !important; }
#research-pane {
height: 90dvh !important;
max-height: 90dvh !important;
@@ -1451,7 +1451,7 @@ body.theme-frosted .modal {
browser's bottom toolbar / safe area and can always be scrolled into view
(otherwise the form fits the viewport and there's no room to reach them). */
#research-pane .research-pane-body {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 40px) !important;
padding-bottom: calc(var(--safe-bottom) + 40px) !important;
touch-action: pan-y;
overscroll-behavior: contain;
}
+7
View File
@@ -16,6 +16,13 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
-7
View File
@@ -6929,13 +6929,6 @@ import { invalidateSettings } from './appConfig.js';
}
});
}
// Smooth viewport resize when keyboard opens/closes
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
});
document.documentElement.style.setProperty('--vh', window.visualViewport.height + 'px');
}
}
// If the browser discarded and restored this tab, reload the current session
+5 -4
View File
@@ -22,7 +22,7 @@ import {
} from './utils.js';
import { state } from './state.js';
import { getSettings } from '../appConfig.js';
import { collapseSidebarToRail } from '../modalSnap.js';
import { clampWindowLeft, collapseSidebarToRail, leftNavRight, safeInsetPx } from '../modalSnap.js';
import { emailApiUrl } from '../emailShared.js';
import {
bindMenuDismiss,
@@ -823,7 +823,7 @@ function _readCssPx(name) {
}
function _emailSplitLeftEdge() {
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
return leftNavRight();
}
function _setEmailDocumentSplit(leftEdge, emailWidth) {
@@ -899,7 +899,8 @@ function _clearEmailDocumentSplit() {
// rail. Used by the "try collapsing the sidebar first" path so we can decide
// whether collapsing recovers enough room before minimizing email.
function _emailSplitLeftEdgeIfSidebarCollapsed() {
return _readCssPx('--icon-rail-w');
const navOnRight = document.getElementById('sidebar')?.classList.contains('right-side');
return safeInsetPx('left') + (navOnRight ? 0 : _readCssPx('--icon-rail-w'));
}
function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
@@ -2415,7 +2416,7 @@ export function openEmailLibrary(opts = {}) {
requestAnimationFrame(() => {
const w = content.offsetWidth;
const refH = window.innerHeight * 0.85;
content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
content.style.transform = 'none';
});
+2 -1
View File
@@ -11,6 +11,7 @@
import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js';
import { clampWindowLeft } from '../modalSnap.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js';
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
@@ -428,7 +429,7 @@ export async function _openEmailWindow(em, folder) {
requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28;
content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
content.style.left = clampWindowLeft(Math.max(20, (window.innerWidth - w) / 2 + off), w) + 'px';
content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
});
}
+1 -1
View File
@@ -190,7 +190,7 @@ export function createPanel({
// which left it as a side panel squeezing the chat).
if (window.innerWidth <= 768) {
pane.style.position = 'fixed';
pane.style.inset = '0';
pane.style.inset = 'var(--sheet-top) 0 0';
pane.style.width = '100%';
pane.style.maxWidth = '100%';
pane.style.zIndex = '170';
+25
View File
@@ -369,6 +369,31 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
/* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
{
if (window.visualViewport) {
const root = document.documentElement;
// iOS overlays the keyboard without resizing the layout viewport and scrolls
// the page to reveal the field, leaving it shifted after the keyboard closes.
const _syncKeyboardViewport = function() {
const vv = window.visualViewport;
if (vv.scale > 1.01) return;
const keyboardOpen = window.innerHeight - vv.height > 120;
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
const _syncThroughKeyboardAnimation = () => {
_syncKeyboardViewport();
for (const delay of [100, 300, 600, 1000]) setTimeout(_syncKeyboardViewport, delay);
};
_syncKeyboardViewport();
window.visualViewport.addEventListener('resize', _syncThroughKeyboardAnimation);
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
const vv = window.visualViewport;
+45 -7
View File
@@ -110,7 +110,7 @@ function _clampRightDockWidth(width) {
const min = _minEdgeDockWidth();
const navRight = _leftNavRight();
const leftDockW = _activeDockWidth('left');
const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH;
const maxByChat = window.innerWidth - navRight - _rightNavInset() - leftDockW - MIN_CHAT_WIDTH;
const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
return _clampDockWidthToSpace(width, min, max);
}
@@ -118,7 +118,7 @@ function _clampRightDockWidth(width) {
function _clampLeftDockWidth(width, left = _leftNavRight()) {
const min = _minEdgeDockWidth();
const rightDockW = _activeDockWidth('right');
const available = Math.max(0, window.innerWidth - left - rightDockW);
const available = Math.max(0, window.innerWidth - left - _rightNavInset() - rightDockW);
const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
return _clampDockWidthToSpace(width, min, max);
}
@@ -170,13 +170,23 @@ function _shouldAutoCollapseSidebar(dockW) {
return remaining < MIN_CHAT_WIDTH;
}
function _navOnRight() {
return !!document.getElementById('sidebar')?.classList.contains('right-side');
}
// Body padding carries the device safe-area insets (notch, Dynamic Island).
function _bodyPaddingPx(side) {
return parseFloat(window.getComputedStyle(document.body)[side === 'left' ? 'paddingLeft' : 'paddingRight']) || 0;
}
// Right edge (px) of whatever left navigation is currently showing — the
// expanded sidebar if visible, otherwise the icon rail. Used to anchor the
// left dock so it always sits flush to the right of the nav.
function _leftNavRight() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let x = 0;
let x = _bodyPaddingPx('left');
if (_navOnRight()) return x;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) x = Math.max(x, r.right);
@@ -188,8 +198,36 @@ function _leftNavRight() {
return x;
}
// Distance (px) from the viewport's right edge that a right-side nav and the
// safe area occupy. The right dock sits flush to the left of it.
function _rightNavInset() {
const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail');
let inset = _bodyPaddingPx('right');
if (!_navOnRight()) return inset;
if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
if (rail && window.getComputedStyle(rail).display !== 'none') {
const r = rail.getBoundingClientRect();
if (r.width) inset = Math.max(inset, window.innerWidth - r.left);
}
return inset;
}
export function leftNavRight() { return _leftNavRight(); }
export function rightNavInset() { return _rightNavInset(); }
export function safeInsetPx(side) { return _bodyPaddingPx(side); }
// Keeps a floating window between the nav and the safe areas
export function clampWindowLeft(left, width) {
const max = window.innerWidth - _rightNavInset() - width;
return Math.max(_leftNavRight(), Math.min(left, max));
}
function _clampEmailDocSplitWidth(width, left = _leftNavRight()) {
const available = Math.max(0, window.innerWidth - left);
const available = Math.max(0, window.innerWidth - left - _rightNavInset());
if (!available) return 0;
const compact = available < 760;
const minEmail = compact ? 260 : 340;
@@ -235,7 +273,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
if (!docPane || window.innerWidth <= 768) return;
docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${x}px`, 'important');
docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important');
docPane.style.setProperty('right', `calc(var(--right-dock-w, 0px) + ${_rightNavInset()}px)`, 'important');
docPane.style.setProperty('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important');
@@ -497,7 +535,7 @@ function _applyDockInternal(modal, side, dockClass) {
} else {
w = _resolveRightDockWidth(modal, content);
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
@@ -872,7 +910,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
w = _clampRightDockWidth(window.innerWidth - clientX);
content._userDockWidth = w;
content.style.left = 'auto';
content.style.right = '0';
content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
+5 -1
View File
@@ -75,12 +75,15 @@ export function initSidebarLayout(Storage, opts) {
_setSidebarModeClasses(mode);
}
// Phones in landscape get the desktop layout, but the icon rail is too small to use there
const _phoneLandscape = window.matchMedia('(pointer: coarse) and (max-height: 500px)');
function _syncRailSideCore() {
const sidebar = document.getElementById('sidebar');
if (!iconRail) return;
const isRight = sidebar.classList.contains('right-side');
const sidebarHidden = sidebar.classList.contains('hidden');
const railHidden = iconRail.classList.contains('rail-hidden');
const railHidden = iconRail.classList.contains('rail-hidden') || _phoneLandscape.matches;
const isMobileMini = iconRail.classList.contains('mobile-mini');
iconRail.classList.toggle('right-side', isRight);
// On mobile mini mode, JS already set inline styles — don't touch
@@ -118,6 +121,7 @@ export function initSidebarLayout(Storage, opts) {
// Set initial reference and expose globally
_syncRailSideFn = _syncRailSideCore;
window.syncRailSide = syncRailSide;
_phoneLandscape.addEventListener('change', () => syncRailSide());
// Restore sidebar side preference
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
+8 -1
View File
@@ -257,12 +257,19 @@ export function makeWindowResizable(content, options = {}) {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) {
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
const bodyStyle = getComputedStyle(document.body);
const padLeft = parseFloat(bodyStyle.paddingLeft) || 0;
const padRight = parseFloat(bodyStyle.paddingRight) || 0;
const w = Math.max(minW, Math.min(saved.w, window.innerWidth - padLeft - padRight));
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px';
content.style.height = h + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
const left = parseFloat(content.style.left);
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
}
}
} catch (_) {}
});