From 76bda7af13a2f9514ecbb4b3d223a679eb61df2e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 10 Jun 2026 13:14:14 -0400 Subject: [PATCH] Popups: always clamp into the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positionPopup clamped horizontally but never vertically: the flip-above branch positioned tall popups (rich layouts, lead figures) above the visible region whenever the target sat near the top of the screen. Placement is now: below if it fits, above if THAT fits, else the roomier side — then clamped into the viewport on both axes. .link-popup is additionally capped at viewport height (matching GAP, overflow-y: auto) so the clamp always has room to work, and a dimension-less image that loads after positioning re-clamps instead of growing past the edge. Verified by simulating the clamp across five viewport scenarios: the near-top bug case moves from 470px above the viewport to fully visible; the fits-below and flip-above cases are unchanged. Co-Authored-By: Claude Fable 5 --- static/css/popups.css | 6 ++++++ static/js/popups.js | 36 +++++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/static/css/popups.css b/static/css/popups.css index ee09762..5d1ce7d 100644 --- a/static/css/popups.css +++ b/static/css/popups.css @@ -10,6 +10,12 @@ z-index: 500; max-width: 420px; min-width: 200px; + /* Never taller than the viewport: positionPopup clamps the popup + into the visible region, which only works if the box itself can + fit there. The 10px matches positionPopup's GAP on each side; + overflow scrolls the rare popup that still exceeds the cap. */ + max-height: calc(100vh - 20px); + overflow-y: auto; padding: 0.7rem 0.9rem; background: var(--bg); border: 1px solid var(--border); diff --git a/static/js/popups.js b/static/js/popups.js index dfa1e86..26fc536 100644 --- a/static/js/popups.js +++ b/static/js/popups.js @@ -215,6 +215,19 @@ positionPopup(target); popup.classList.add('is-visible'); popup.setAttribute('aria-hidden', 'false'); + /* Images with width/height attrs reserve their space + before load; one without them grows the popup after + positioning and can push it past the viewport edge. + Re-clamp when such an image arrives. */ + popup.querySelectorAll('img:not([height])').forEach(function (im) { + if (im.complete) return; + im.addEventListener('load', function () { + if (activeTarget === target && + popup.classList.contains('is-visible')) { + positionPopup(target); + } + }, { once: true }); + }); }).catch(function () { /* silently fail */ }); }, SHOW_DELAY); } @@ -247,9 +260,26 @@ var left = rect.left + sx + rect.width / 2 - pw / 2; left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP)); - var top = (rect.bottom + GAP + ph <= vh) - ? rect.bottom + sy + GAP - : rect.top + sy - ph - GAP; + /* Below if it fits, else above if THAT fits, else whichever + side has more room. The final clamp guarantees the popup + never extends past either viewport edge — without it, the + flip-above branch positions tall popups (rich layouts, lead + figures) above the visible region for targets near the top + of the screen. CSS caps .link-popup at viewport height + (same 10px gap), so the clamp always has room to work. */ + var fitsBelow = rect.bottom + GAP + ph <= vh; + var fitsAbove = rect.top - GAP - ph >= 0; + var top; + if (fitsBelow) { + top = rect.bottom + sy + GAP; + } else if (fitsAbove) { + top = rect.top + sy - ph - GAP; + } else { + top = (vh - rect.bottom >= rect.top) + ? rect.bottom + sy + GAP + : rect.top + sy - ph - GAP; + } + top = Math.max(sy + GAP, Math.min(top, sy + vh - ph - GAP)); popup.style.left = left + 'px'; popup.style.top = top + 'px';