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
+1 -1
View File
@@ -190,7 +190,7 @@ export function createPanel({
// which left it as a side panel squeezing the chat). // which left it as a side panel squeezing the chat).
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
pane.style.position = 'fixed'; pane.style.position = 'fixed';
pane.style.inset = '0'; pane.style.inset = 'var(--sheet-top) 0 0';
pane.style.width = '100%'; pane.style.width = '100%';
pane.style.maxWidth = '100%'; pane.style.maxWidth = '100%';
pane.style.zIndex = '170'; pane.style.zIndex = '170';
+26 -1
View File
@@ -82,6 +82,28 @@
/* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so /* Warm accent — used by the Goals/Today UI in Notes. Lives as a token so
themes can override without touching the goal CSS. */ themes can override without touching the goal CSS. */
--accent-warm: #d19a66; --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 { :root.light {
@@ -121,9 +143,12 @@ body {
font-family: var(--font-family, 'Fira Code', monospace); font-family: var(--font-family, 'Fira Code', monospace);
display: flex; display: flex;
height: 100%; 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; 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 */ /* 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'); } @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 */ /* Fixed hamburger — always visible, toggles sidebar */
.hamburger-btn { .hamburger-btn {
position: fixed; position: fixed;
top: 12px; top: calc(12px + var(--safe-top));
left: 9px; left: calc(9px + var(--safe-left));
right: auto; right: auto;
z-index: 210; z-index: 210;
width: 30px; width: 30px;
@@ -583,7 +583,7 @@ body.bg-pattern-ascii-fireflies {
} }
body.hamburger-right .hamburger-btn { body.hamburger-right .hamburger-btn {
left: auto; left: auto;
right: 9px; right: calc(9px + var(--safe-right));
} }
.mobile-new-chat-btn { .mobile-new-chat-btn {
display: none; display: none;
@@ -655,6 +655,25 @@ body.bg-pattern-ascii-fireflies {
border-right: none; border-right: none;
border-left: 1px solid var(--border); 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 { .icon-rail-divider {
width: 24px; width: 24px;
height: 1px; height: 1px;
@@ -4800,7 +4819,7 @@ body.bg-pattern-ascii-fireflies {
.toast { .toast {
position:fixed; position:fixed;
top: 16px; right: 16px; top: calc(16px + var(--safe-top)); right: calc(16px + var(--safe-right));
left: auto; bottom: auto; left: auto; bottom: auto;
background:var(--panel); color:var(--fg); background:var(--panel); color:var(--fg);
border:1px solid color-mix(in srgb, var(--accent) 30%, transparent); 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%); } .toast.toast-panel-offset.exiting { transform: translateX(-120%); }
@media (max-width: 768px) { @media (max-width: 768px) {
.toast { .toast {
top: 12px; top: calc(12px + var(--safe-top));
right: 12px; right: calc(12px + var(--safe-right));
max-width: calc(100vw - 24px); max-width: calc(100vw - 24px);
/* Receive touches so the swipe-to-dismiss gesture works (desktop keeps /* Receive touches so the swipe-to-dismiss gesture works (desktop keeps
pointer-events:none so the toast never blocks clicks). Horizontal pan pointer-events:none so the toast never blocks clicks). Horizontal pan
@@ -5109,6 +5128,7 @@ body.bg-pattern-ascii-fireflies {
--sidebar-bg. */ --sidebar-bg. */
background: var(--sidebar-bg, var(--panel)) !important; background: var(--sidebar-bg, var(--panel)) !important;
backdrop-filter: none !important; backdrop-filter: none !important;
padding: var(--safe-top) 0 var(--safe-bottom) var(--safe-left);
} }
.sidebar.hidden { .sidebar.hidden {
width: 80% !important; width: 80% !important;
@@ -5122,6 +5142,8 @@ body.bg-pattern-ascii-fireflies {
.sidebar.right-side { .sidebar.right-side {
left: auto; right: 0; left: auto; right: 0;
box-shadow: -4px 0 20px rgba(0,0,0,0.5); 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-sidebar-off .sidebar,
html.ody-mobile-startup-sidebar-hidden .sidebar { html.ody-mobile-startup-sidebar-hidden .sidebar {
@@ -5315,9 +5337,9 @@ body.bg-pattern-ascii-fireflies {
.hamburger-btn { .hamburger-btn {
width: 44px; width: 44px;
height: 44px; height: 44px;
top: 6px; top: calc(6px + var(--safe-top));
left: auto !important; left: auto !important;
right: 4px !important; right: calc(4px + var(--safe-right)) !important;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.hamburger-btn:hover, .hamburger-btn:hover,
@@ -5330,8 +5352,8 @@ body.bg-pattern-ascii-fireflies {
.mobile-new-chat-btn { .mobile-new-chat-btn {
display: flex; display: flex;
position: fixed; position: fixed;
top: 12px; top: calc(12px + var(--safe-top));
left: 8px; left: calc(8px + var(--safe-left));
z-index: 210; z-index: 210;
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -5400,10 +5422,6 @@ body.bg-pattern-ascii-fireflies {
padding: 6px !important; padding: 6px !important;
border-radius: 12px !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 — larger touch targets */
.mode-toggle { .mode-toggle {
height: 34px; height: 34px;
@@ -5447,15 +5465,15 @@ body.bg-pattern-ascii-fireflies {
URL-bar show/hide. Order matters: later same-specificity rule URL-bar show/hide. Order matters: later same-specificity rule
wins, so dvh must come after vh. */ wins, so dvh must come after vh. */
max-height: 100vh !important; max-height: 100vh !important;
max-height: 100dvh !important; max-height: calc(var(--app-height) - var(--sheet-top)) !important;
height: 100vh; height: 100vh;
height: 100dvh; height: calc(var(--app-height) - var(--sheet-top));
border-radius: 14px 14px 0 0 !important; border-radius: 14px 14px 0 0 !important;
border: none !important; border: none !important;
border-top: 1px solid var(--border) !important; border-top: 1px solid var(--border) !important;
box-shadow: none !important; box-shadow: none !important;
padding: 6px !important; padding: 6px !important;
padding-bottom: env(safe-area-inset-bottom, 6px) !important; padding-bottom: var(--safe-bottom) !important;
margin: 0 !important; margin: 0 !important;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -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 (against the flex-sized grid) as a definite height, so grid/flex
children never filled. An explicit px height is unambiguous. */ children never filled. An explicit px height is unambiguous. */
margin: 0 !important; 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; background: var(--bg) !important;
/* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on /* Flex column. JS (_fillSkillCardHeight) sets EXPLICIT px heights on
the preview + <pre>; in a flex column an explicit-height item 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 — /* Small bottom padding now that the load-more no longer steals space —
the action bar sits low, just clearing the home-indicator safe area. */ 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 { #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 pre,
#doclib-modal .doclib-card.doclib-card-expanded .doclib-card-preview .doclib-card-pdf-frame, #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; z-index:250;
backdrop-filter:none; backdrop-filter:none;
pointer-events: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 always sits above Gallery so the "Serve a model in
Cookbook…" flow (and any other "open Cookbook from inside another 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. */ centered window glides instead of jumping when the nav toggles. */
transition: left 0.25s ease, width 0.25s ease; 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 { .modal-content {
background:var(--panel); background:var(--panel);
@@ -6689,8 +6719,8 @@ body.bg-pattern-ascii-fireflies {
/* Agent indicator */ /* Agent indicator */
#agent-indicator { #agent-indicator {
position: fixed; position: fixed;
top: 20px; top: calc(20px + var(--safe-top));
right: 20px; right: calc(20px + var(--safe-right));
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -7016,7 +7046,7 @@ body.bg-pattern-ascii-fireflies {
#compare-model-overlay .modal-content { #compare-model-overlay .modal-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: min(720px, calc(100dvh - 48px)); max-height: min(720px, calc(var(--app-height) - 48px));
overflow: hidden; overflow: hidden;
min-height: 180px; min-height: 180px;
} }
@@ -7433,7 +7463,7 @@ body.bg-pattern-ascii-fireflies {
#recording-indicator { #recording-indicator {
position: fixed; position: fixed;
top: 10px; top: calc(10px + var(--safe-top));
left: 0; left: 0;
right: 0; right: 0;
background: rgba(0, 0, 0, 0.8); 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 position:fixed; height:100% = the layout viewport, whose bottom sits
UNDER Firefox Android's bottom URL bar — so flex-end parked the sheet 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 (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. */ so the footer lands above it. */
height: 100dvh; height: var(--app-height);
padding-bottom: env(safe-area-inset-bottom, 0px);
} }
/* Confirm dialog stays centered, not a bottom sheet */ /* Confirm dialog stays centered, not a bottom sheet */
#styled-confirm-overlay { #styled-confirm-overlay {
@@ -204,7 +203,7 @@
border-radius: 14px 14px 0 0; border-radius: 14px 14px 0 0;
border: none; border: none;
border-top: 1px solid var(--border); 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; animation: sheet-enter 0.2s ease-out forwards;
overflow-y: hidden !important; overflow-y: hidden !important;
} }
@@ -252,12 +251,12 @@
#email-lib-modal .modal-content, #email-lib-modal .modal-content,
#doclib-modal .doclib-modal-content { #doclib-modal .doclib-modal-content {
max-height: 90vh !important; max-height: 90vh !important;
max-height: 90dvh !important; max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important; height: auto !important;
/* Reserve the iOS home-indicator / bottom-bar strip so an expanded /* Reserve the iOS home-indicator / bottom-bar strip so an expanded
skill card's footer (Delete / Edit / Run) isn't hidden under it. skill card's footer (Delete / Edit / Run) isn't hidden under it.
dvh already excludes the URL bar; this handles the safe area. */ 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 /* Grab handle pill on the non-standard content classes too. Memory's
desktop rule defines a radial-glow ::before later in the file — 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 visible viewport on Chrome/Safari mobile (URL bar covered the
action buttons row). */ action buttons row). */
max-height: 90vh !important; max-height: 90vh !important;
max-height: 90dvh !important; max-height: calc(var(--app-height) * 0.9) !important;
height: auto !important; height: auto !important;
} }
/* Anchor those modals to the top so the full height is usable */ /* Anchor those modals to the top so the full height is usable */
@@ -314,8 +313,11 @@
#email-lib-modal, #email-lib-modal,
#doclib-modal, #doclib-modal,
#gallery-modal, #gallery-modal,
#tasks-modal { #tasks-modal,
padding-top: 0 !important; #memory-modal,
#settings-modal {
padding-top: var(--sheet-top) !important;
padding-bottom: 0 !important;
align-items: stretch !important; align-items: stretch !important;
} }
/* Deep Research already gets the swipe grab-handle pill from the /* 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 */
#agent-indicator { #agent-indicator {
position: fixed; position: fixed;
top: 20px; top: calc(20px + var(--safe-top));
right: 20px; right: calc(20px + var(--safe-right));
background: var(--sidebar-bg, var(--panel)); background: var(--sidebar-bg, var(--panel));
color: var(--fg); color: var(--fg);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -894,7 +894,7 @@ button.hamburger {
/* Recording indicator */ /* Recording indicator */
#recording-indicator { #recording-indicator {
position: fixed; position: fixed;
top: 10px; top: calc(10px + var(--safe-top));
left: 0; left: 0;
right: 0; right: 0;
background: rgba(0, 0, 0, 0.8); 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. */ render it as a narrow side pane ("sidebar") on a phone. */
position: fixed !important; position: fixed !important;
inset: 0 !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; max-width: 100% !important;
width: 100% !important; width: 100% !important;
flex: none !important; flex: none !important;
z-index: 170; z-index: 170;
padding-bottom: var(--safe-bottom);
/* Stroke the top edge so the rounded corners read as a curved sheet edge. */ /* Stroke the top edge so the rounded corners read as a curved sheet edge. */
border: 1px solid var(--border); border: 1px solid var(--border);
border-bottom: none; border-bottom: none;
+16 -10
View File
@@ -1181,8 +1181,8 @@ span[id$="-logo"] svg:not([width]) {
opacity: 0; opacity: 0;
transform-origin: center center; transform-origin: center center;
position: absolute; position: absolute;
right: calc(16px + env(safe-area-inset-right, 0px)); right: calc(16px + var(--safe-right));
bottom: calc(18px + env(safe-area-inset-bottom, 0px)); bottom: calc(18px + var(--safe-bottom));
height: var(--fab-size); height: var(--fab-size);
min-width: var(--fab-size); min-width: var(--fab-size);
padding: 0 20px 0 18px; padding: 0 20px 0 18px;
@@ -1298,17 +1298,23 @@ span[id$="-logo"] svg:not([width]) {
instead of becoming a side panel. */ instead of becoming a side panel. */
#doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content { #doclib-modal.doclib-fullscreen:not(.modal-right-docked) .doclib-modal-content {
position: fixed !important; position: fixed !important;
top: 0 !important; top: var(--safe-top) !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important; left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important; right: var(--safe-right) !important;
bottom: 0 !important; bottom: var(--safe-bottom) !important;
width: auto !important; width: auto !important;
max-width: none !important; max-width: none !important;
height: 100vh !important; height: auto !important;
max-height: 100vh !important; max-height: none !important;
border-radius: 0 !important; border-radius: 0 !important;
transform: none !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 .modal-header,
#doclib-modal.doclib-fullscreen .lib-tabs, #doclib-modal.doclib-fullscreen .lib-tabs,
#doclib-modal.modal-right-docked .modal-header, #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 panes reserve room with margins so left + right docks can be active at the
same time without skewing the entire body box. */ same time without skewing the entire body box. */
body.right-dock-active { body.right-dock-active {
padding-right: 0; padding-right: var(--safe-right);
} }
body.left-dock-active { body.left-dock-active {
padding-left: 0; padding-left: var(--safe-left);
} }
body.left-dock-active:not(.email-doc-split-active) .chat-container { body.left-dock-active:not(.email-doc-split-active) .chat-container {
margin-left: var(--left-dock-w, 0px); 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. */ edges and let the inner body absorb the remaining height. */
body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .modal-content { body:not(.email-doc-split-active) .email-window-modal.email-window-fullscreen .modal-content {
position: fixed !important; position: fixed !important;
inset: 0 !important; inset: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left) !important;
left: 0 !important; width: auto !important;
top: 0 !important; max-width: none !important;
width: 100vw !important; height: auto !important;
max-width: 100vw !important; max-height: none !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important; border-radius: 0 !important;
transform: none !important; transform: none !important;
display: flex !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). */ 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 { body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
position: fixed !important; position: fixed !important;
top: 0 !important; top: var(--safe-top) !important;
left: calc(var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important; left: calc(var(--safe-left) + var(--icon-rail-w, 48px) + var(--sidebar-w, 0px)) !important;
right: 0 !important; right: var(--safe-right) !important;
bottom: 0 !important; bottom: var(--safe-bottom) !important;
width: auto !important; width: auto !important;
max-width: none !important; max-width: none !important;
height: 100vh !important; height: auto !important;
max-height: 100vh !important; max-height: none !important;
border-radius: 0 !important; border-radius: 0 !important;
transform: none !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- /* 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 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, 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. */ so it wins on mobile. */
@media (max-width: 768px) { @media (max-width: 768px) {
body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content { body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.modal-right-docked) .modal-content {
left: 0 !important; top: var(--sheet-top) !important;
right: 0 !important; left: var(--safe-left) !important;
width: 100vw !important; right: var(--safe-right) !important;
max-width: 100vw !important; bottom: 0 !important;
height: 100dvh !important; width: auto !important;
max-height: 100dvh !important; max-width: none !important;
height: auto !important;
max-height: none !important;
border-radius: 14px 14px 0 0 !important; border-radius: 14px 14px 0 0 !important;
border-top: 1px solid var(--border) !important; border-top: 1px solid var(--border) !important;
} }
+1 -1
View File
@@ -4502,7 +4502,7 @@ button .spinner-whirlpool {
left: 8px !important; left: 8px !important;
right: 8px !important; right: 8px !important;
top: auto !important; top: auto !important;
bottom: calc(env(safe-area-inset-bottom, 0px) + 8px) !important; bottom: calc(var(--safe-bottom) + 8px) !important;
width: auto !important; width: auto !important;
min-width: 0 !important; min-width: 0 !important;
max-width: calc(100vw - 16px) !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) { @media (max-width: 768px) {
body.notes-view .notes-pane { body.notes-view .notes-pane {
position: fixed; position: fixed;
inset: 0; inset: var(--sheet-top) 0 0;
max-width: 100%; max-width: 100%;
width: 100% !important; width: 100% !important;
/* Desktop sets height: min(80vh, 820px) which wins over inset:0's bottom:0 /* 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. */ and leaves a gap at the bottom of the viewport. Force full height here. */
height: 100dvh !important; height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: 100dvh !important; max-height: calc(var(--app-height) - var(--sheet-top)) !important;
padding-bottom: var(--safe-bottom);
z-index: 170; z-index: 170;
/* Bottom-sheet treatment like the document editor: stroked, rounded top, /* Bottom-sheet treatment like the document editor: stroked, rounded top,
slides up from the bottom. */ 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-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 bottom, so there's a small gap at the top and it reads as a sheet. The body
still scrolls within the bounded height. */ 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 { #research-pane {
height: 90dvh !important; height: 90dvh !important;
max-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 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). */ (otherwise the form fits the viewport and there's no room to reach them). */
#research-pane .research-pane-body { #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; touch-action: pan-y;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
+7
View File
@@ -16,6 +16,13 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png"> <link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}"> <script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now(); 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(){ (function(){
try { try {
var t = JSON.parse(localStorage.getItem('odysseus-theme')); 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 // If the browser discarded and restored this tab, reload the current session
+5 -4
View File
@@ -22,7 +22,7 @@ import {
} from './utils.js'; } from './utils.js';
import { state } from './state.js'; import { state } from './state.js';
import { getSettings } from '../appConfig.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 { emailApiUrl } from '../emailShared.js';
import { import {
bindMenuDismiss, bindMenuDismiss,
@@ -823,7 +823,7 @@ function _readCssPx(name) {
} }
function _emailSplitLeftEdge() { function _emailSplitLeftEdge() {
return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w'); return leftNavRight();
} }
function _setEmailDocumentSplit(leftEdge, emailWidth) { function _setEmailDocumentSplit(leftEdge, emailWidth) {
@@ -899,7 +899,8 @@ function _clearEmailDocumentSplit() {
// rail. Used by the "try collapsing the sidebar first" path so we can decide // rail. Used by the "try collapsing the sidebar first" path so we can decide
// whether collapsing recovers enough room before minimizing email. // whether collapsing recovers enough room before minimizing email.
function _emailSplitLeftEdgeIfSidebarCollapsed() { 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 = {}) { function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) {
@@ -2415,7 +2416,7 @@ export function openEmailLibrary(opts = {}) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const w = content.offsetWidth; const w = content.offsetWidth;
const refH = window.innerHeight * 0.85; 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.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
content.style.transform = 'none'; content.style.transform = 'none';
}); });
+2 -1
View File
@@ -11,6 +11,7 @@
import spinnerModule from '../spinner.js'; import spinnerModule from '../spinner.js';
import * as Modals from '../modalManager.js'; import * as Modals from '../modalManager.js';
import { clampWindowLeft } from '../modalSnap.js';
import { showToast } from '../ui.js?v=20260916largetoolscroll1'; import { showToast } from '../ui.js?v=20260916largetoolscroll1';
import { state } from './state.js'; import { state } from './state.js';
import { _esc, _extractName, _renderEmailSummaryError } from './utils.js'; import { _esc, _extractName, _renderEmailSummaryError } from './utils.js';
@@ -428,7 +429,7 @@ export async function _openEmailWindow(em, folder) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const w = content.offsetWidth, h = content.offsetHeight; const w = content.offsetWidth, h = content.offsetHeight;
const off = (_emailWindowSeq % 6) * 28; 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'; 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). // which left it as a side panel squeezing the chat).
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
pane.style.position = 'fixed'; pane.style.position = 'fixed';
pane.style.inset = '0'; pane.style.inset = 'var(--sheet-top) 0 0';
pane.style.width = '100%'; pane.style.width = '100%';
pane.style.maxWidth = '100%'; pane.style.maxWidth = '100%';
pane.style.zIndex = '170'; 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 ---- */ /* ---- Mobile viewport fix — keep chat visible when virtual keyboard opens ---- */
{ {
if (window.visualViewport) { 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; let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() { window.visualViewport.addEventListener('resize', function() {
const vv = window.visualViewport; const vv = window.visualViewport;
+45 -7
View File
@@ -110,7 +110,7 @@ function _clampRightDockWidth(width) {
const min = _minEdgeDockWidth(); const min = _minEdgeDockWidth();
const navRight = _leftNavRight(); const navRight = _leftNavRight();
const leftDockW = _activeDockWidth('left'); 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); const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
return _clampDockWidthToSpace(width, min, max); return _clampDockWidthToSpace(width, min, max);
} }
@@ -118,7 +118,7 @@ function _clampRightDockWidth(width) {
function _clampLeftDockWidth(width, left = _leftNavRight()) { function _clampLeftDockWidth(width, left = _leftNavRight()) {
const min = _minEdgeDockWidth(); const min = _minEdgeDockWidth();
const rightDockW = _activeDockWidth('right'); 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); const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
return _clampDockWidthToSpace(width, min, max); return _clampDockWidthToSpace(width, min, max);
} }
@@ -170,13 +170,23 @@ function _shouldAutoCollapseSidebar(dockW) {
return remaining < MIN_CHAT_WIDTH; 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 // Right edge (px) of whatever left navigation is currently showing — the
// expanded sidebar if visible, otherwise the icon rail. Used to anchor 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. // left dock so it always sits flush to the right of the nav.
function _leftNavRight() { function _leftNavRight() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
const rail = document.getElementById('icon-rail'); const rail = document.getElementById('icon-rail');
let x = 0; let x = _bodyPaddingPx('left');
if (_navOnRight()) return x;
if (sidebar && !sidebar.classList.contains('hidden')) { if (sidebar && !sidebar.classList.contains('hidden')) {
const r = sidebar.getBoundingClientRect(); const r = sidebar.getBoundingClientRect();
if (r.width) x = Math.max(x, r.right); if (r.width) x = Math.max(x, r.right);
@@ -188,8 +198,36 @@ function _leftNavRight() {
return x; 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()) { 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; if (!available) return 0;
const compact = available < 760; const compact = available < 760;
const minEmail = compact ? 260 : 340; const minEmail = compact ? 260 : 340;
@@ -235,7 +273,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
if (!docPane || window.innerWidth <= 768) return; if (!docPane || window.innerWidth <= 768) return;
docPane.style.setProperty('position', 'fixed', 'important'); docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${x}px`, '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('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important'); docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important'); docPane.style.setProperty('width', 'auto', 'important');
@@ -497,7 +535,7 @@ function _applyDockInternal(modal, side, dockClass) {
} else { } else {
w = _resolveRightDockWidth(modal, content); w = _resolveRightDockWidth(modal, content);
content.style.left = 'auto'; content.style.left = 'auto';
content.style.right = '0'; content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px'; content.style.width = w + 'px';
content.style.maxWidth = w + 'px'; content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active'); document.body.classList.add('right-dock-active');
@@ -872,7 +910,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
w = _clampRightDockWidth(window.innerWidth - clientX); w = _clampRightDockWidth(window.innerWidth - clientX);
content._userDockWidth = w; content._userDockWidth = w;
content.style.left = 'auto'; content.style.left = 'auto';
content.style.right = '0'; content.style.right = _rightNavInset() + 'px';
content.style.width = w + 'px'; content.style.width = w + 'px';
content.style.maxWidth = w + 'px'; content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active'); document.body.classList.add('right-dock-active');
+5 -1
View File
@@ -75,12 +75,15 @@ export function initSidebarLayout(Storage, opts) {
_setSidebarModeClasses(mode); _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() { function _syncRailSideCore() {
const sidebar = document.getElementById('sidebar'); const sidebar = document.getElementById('sidebar');
if (!iconRail) return; if (!iconRail) return;
const isRight = sidebar.classList.contains('right-side'); const isRight = sidebar.classList.contains('right-side');
const sidebarHidden = sidebar.classList.contains('hidden'); 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'); const isMobileMini = iconRail.classList.contains('mobile-mini');
iconRail.classList.toggle('right-side', isRight); iconRail.classList.toggle('right-side', isRight);
// On mobile mini mode, JS already set inline styles — don't touch // 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 // Set initial reference and expose globally
_syncRailSideFn = _syncRailSideCore; _syncRailSideFn = _syncRailSideCore;
window.syncRailSide = syncRailSide; window.syncRailSide = syncRailSide;
_phoneLandscape.addEventListener('change', () => syncRailSide());
// Restore sidebar side preference // Restore sidebar side preference
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') { if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
+8 -1
View File
@@ -257,12 +257,19 @@ export function makeWindowResizable(content, options = {}) {
try { try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null'); const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) { 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)); const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px'; content.style.width = w + 'px';
content.style.height = h + 'px'; content.style.height = h + 'px';
content.style.maxWidth = 'none'; content.style.maxWidth = 'none';
content.style.maxHeight = '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 (_) {} } catch (_) {}
}); });