From b2613b21064e9e8654ffb90848c9e708e8d9f4ac Mon Sep 17 00:00:00 2001 From: Gr33nbl00d Date: Wed, 23 Sep 2026 20:13:25 +0200 Subject: [PATCH 1/2] fix(ui): respect iOS safe areas, screen height and landscape layout --- frontend/src/features/notes/ui/panel.js | 2 +- static/css/00-tokens.css | 27 ++++++++- static/css/01-agent-chat.css | 76 +++++++++++++++++-------- static/css/02-compare.css | 20 ++++--- static/css/03-agent-chat.css | 6 +- static/css/05-documents.css | 4 +- static/css/06-admin-settings.css | 26 +++++---- static/css/07-documents.css | 44 +++++++------- static/css/13-image-editor.css | 2 +- static/css/14-email.css | 7 ++- static/css/17-research.css | 4 +- static/index.html | 7 +++ static/js/chat.js | 7 --- static/js/emailLibrary/index.js | 9 +-- static/js/emailLibrary/reader.js | 3 +- static/js/features/notes/ui/panel.js | 2 +- static/js/init.js | 25 ++++++++ static/js/modalSnap.js | 52 ++++++++++++++--- static/js/sidebar-layout.js | 6 +- static/js/windowResize.js | 9 ++- 20 files changed, 243 insertions(+), 95 deletions(-) diff --git a/frontend/src/features/notes/ui/panel.js b/frontend/src/features/notes/ui/panel.js index 0a6031233..2265897c0 100644 --- a/frontend/src/features/notes/ui/panel.js +++ b/frontend/src/features/notes/ui/panel.js @@ -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'; diff --git a/static/css/00-tokens.css b/static/css/00-tokens.css index e502f7670..1105ba70c 100644 --- a/static/css/00-tokens.css +++ b/static/css/00-tokens.css @@ -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'); } diff --git a/static/css/01-agent-chat.css b/static/css/01-agent-chat.css index 7db3115a6..506d04e81 100644 --- a/static/css/01-agent-chat.css +++ b/static/css/01-agent-chat.css @@ -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 +
; 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);
diff --git a/static/css/02-compare.css b/static/css/02-compare.css
index ae04ba663..21fd7b555 100644
--- a/static/css/02-compare.css
+++ b/static/css/02-compare.css
@@ -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
diff --git a/static/css/03-agent-chat.css b/static/css/03-agent-chat.css
index 0536fe3b6..0e8c95f91 100644
--- a/static/css/03-agent-chat.css
+++ b/static/css/03-agent-chat.css
@@ -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);
diff --git a/static/css/05-documents.css b/static/css/05-documents.css
index b1bb54a22..c069c10cc 100644
--- a/static/css/05-documents.css
+++ b/static/css/05-documents.css
@@ -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;
diff --git a/static/css/06-admin-settings.css b/static/css/06-admin-settings.css
index 425b9ad6d..788470329 100644
--- a/static/css/06-admin-settings.css
+++ b/static/css/06-admin-settings.css
@@ -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);
diff --git a/static/css/07-documents.css b/static/css/07-documents.css
index ea843c0ca..acfa56ae8 100644
--- a/static/css/07-documents.css
+++ b/static/css/07-documents.css
@@ -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;
   }
diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css
index 257e2805e..1fb216bc2 100644
--- a/static/css/13-image-editor.css
+++ b/static/css/13-image-editor.css
@@ -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;
diff --git a/static/css/14-email.css b/static/css/14-email.css
index 0ef60aca4..f42f6b6d1 100644
--- a/static/css/14-email.css
+++ b/static/css/14-email.css
@@ -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. */
diff --git a/static/css/17-research.css b/static/css/17-research.css
index d2fa2b1d1..c4de5cb75 100644
--- a/static/css/17-research.css
+++ b/static/css/17-research.css
@@ -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;
   }
diff --git a/static/index.html b/static/index.html
index 5fc386a5c..89836d27a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -16,6 +16,13 @@