From 9e3dfcb8229e0cb3d8e296acd09cf5e2acb9565e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 2 Sep 2026 09:49:41 +0200 Subject: fix(hub): the show detail modal must not move when the season does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous pass fixed the synopsis and the cast, and the dialog still jumped: the episode count moves things a fixed-height synopsis cannot reach. - The body scrolled as a whole, so a thirteen-episode season pushed the modal to its max-height where a six-episode one had not. `.video-overlay` centres its child, so the taller modal also *started higher up the screen* — title bar, close button and all. `.video-detail-steady` (a multi-season show only) gives the modal a height rather than a max-height, makes the body a flex column, and hands the leftover to the episode list as the one scrolling part. A constant-height box is centred in the same place every time, so both halves settle at once. - A scrolling season draws a scrollbar where a non-scrolling one draws none, which is a scrollbar's width of content and re-wrapped the file path above it, shifting everything below by a line. `scrollbar-gutter: stable`. - The season panel was clipped by the modal's own `overflow: hidden` whenever the seasons outran the room under the picker — at a 740px viewport it wanted 320px and had 288, and the rest sat where no scroll could reach it. It is `position: fixed` now, placed by `placeSeasonPanel()`, which takes the trigger's rect and the window height, picks whichever side has more room, and caps the panel to it. Scoped to multi-season shows throughout: a movie has no season to switch to and a fixed height would buy it nothing but empty space. test_video_detail_measured.py now builds each block inside a real `.video-overlay`, since the centring is half the defect, and asserts the modal top and height as well as the picker's offset — for a long and a short synopsis and for a six- and a twenty-four-episode season. test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and a window height. Two guards are declarations rather than rectangles and say so in their docstrings: headless Chrome gives the probe zero-width overlay scrollbars, so the gutter cannot be measured there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML --- .../src/meshbay_hub/static/video-app.js | 52 ++++++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index c709480..2fa22d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -459,8 +459,36 @@ function OverviewText({ text, reserve }) { // whether the show ran three seasons or twenty-five, which is also what keeps // the episode list below from moving when the season changes. +// Where the panel goes, measured from the trigger. It cannot simply be an +// absolutely positioned child: the modal clips (`overflow: hidden`, for its +// rounded corners), and a show with a dozen seasons opens a panel taller than +// the room left under the picker on anything but a tall window — the last +// seasons then sit outside the modal where no scroll can reach them. Fixed to +// the viewport and measured, it also flips above the trigger when that is +// where the space is. +const SEASON_PANEL_MAX = 320; +const SEASON_PANEL_MIN = 120; +const SEASON_PANEL_GAP = 12; + +function placeSeasonPanel(el) { + if (!el) return null; + const r = el.getBoundingClientRect(); + const below = window.innerHeight - r.bottom - SEASON_PANEL_GAP; + const above = r.top - SEASON_PANEL_GAP; + const down = below >= Math.min(SEASON_PANEL_MAX, above); + const room = Math.max(SEASON_PANEL_MIN, Math.min(SEASON_PANEL_MAX, down ? below : above)); + return { + left: `${r.left}px`, + width: `${r.width}px`, + top: down ? `${r.bottom + 4}px` : 'auto', + bottom: down ? 'auto' : `${window.innerHeight - r.top + 4}px`, + maxHeight: `${room}px`, + }; +} + function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { const [open, setOpen] = useState(false); + const [pos, setPos] = useState(null); const ref = useRef(null); useEffect(() => { @@ -477,28 +505,44 @@ function SeasonMenu({ seasons, selected, selectedYear, onSelect }) { e.stopPropagation(); setOpen(false); }; + // Nothing scrolls under an open panel — the detail body does not scroll + // for a show (only its episode list does) and the page behind the overlay + // cannot — so a resize is the only thing that can invalidate the + // measurement taken when it opened. + const replace = () => setPos(placeSeasonPanel(ref.current)); document.addEventListener('click', close); document.addEventListener('keydown', onKey, true); + window.addEventListener('resize', replace); return () => { document.removeEventListener('click', close); document.removeEventListener('keydown', onKey, true); + window.removeEventListener('resize', replace); }; }, [open]); + // Measured in the click, not in an effect after it: an effect would render + // the panel once at the wrong place and move it on the next frame. + const toggle = useCallback(() => { + setOpen((wasOpen) => { + if (!wasOpen) setPos(placeSeasonPanel(ref.current)); + return !wasOpen; + }); + }, []); + const label = (n) => (n === 0 ? t('video.specials') : t('video.season_n', { n })); return html`
- ${open && html` -
+ ${open && pos && html` +
${seasons.map((s) => html`