aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 09:49:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 09:49:41 +0200
commit9e3dfcb8229e0cb3d8e296acd09cf5e2acb9565e (patch)
tree70424b9745a462c16947779f14b4b51bf37f20f3 /packages/meshbay-hub/src
parent12b6dc4dd3e009f2e844d87181800aa12d07a3a6 (diff)
downloadmeshbay-9e3dfcb8229e0cb3d8e296acd09cf5e2acb9565e.tar.gz
fix(hub): the show detail modal must not move when the season does
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css45
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js52
2 files changed, 88 insertions, 9 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index cb8cd44..c33c91a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -3025,6 +3025,31 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
display: flex;
flex-direction: column;
}
+
+/* A multi-season show: one size, one position, every season.
+ Everything above the episode list is already a fixed height (three lines of
+ synopsis, two of cast), so the last thing that could move the season picker
+ is the list itself — the body scrolled as a whole, and a season with twice
+ the episodes pushed the modal to its max height. That moved the modal as
+ well as its contents: `.video-overlay` centres its child, so the taller
+ season *started higher up the screen*, title bar and all. A constant height
+ settles both — centred, a box of constant height is centred in the same
+ place — which is why this is a `height` and not a `max-height`. Scoped to multi-season shows on purpose: a movie or a
+ single-season show has no season to switch to, and a fixed height would buy
+ it nothing but empty space. Capped at 760px so a tall desktop window does
+ not turn a six-episode season into a mostly empty column. */
+.video-detail.video-detail-steady { height: min(calc(100vh - 100px), 760px); }
+.video-detail.video-detail-steady .video-detail-body { overflow: hidden; }
+.video-detail.video-detail-steady .video-season-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ /* Reserved whether or not this particular season overflows: without it a
+ scrolling season is a scrollbar narrower than a non-scrolling one, which
+ re-wraps the file path above it and shifts everything below by a line —
+ the same jump by a different route. */
+ scrollbar-gutter: stable;
+}
.video-detail .video-top-bar {
position: static;
background: var(--bg-raised);
@@ -3037,6 +3062,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.video-detail-body {
padding: 16px 20px;
overflow-y: auto;
+ /* A column so the episode list can be given the leftover height and scroll
+ inside it. Safe against the usual flex surprise — `* { margin: 0 }` at the
+ top of this file means there are no collapsing margins here to lose. */
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
}
/* The synopsis, and why it is not a -webkit-line-clamp.
Three lines, the third ending in "… Read more" rather than at the right
@@ -3262,18 +3293,22 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.video-season-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.video-season-caret { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-dim); }
+/* Fixed to the viewport, with left/width/top-or-bottom/max-height supplied by
+ placeSeasonPanel() in video-app.js — see the comment there. Absolutely
+ positioned inside the modal it was clipped by the modal's own
+ `overflow: hidden` whenever the seasons outran the room under the picker. */
.video-season-options {
- position: absolute;
- left: 0; right: 0; top: calc(100% + 4px);
- z-index: 40;
+ position: fixed;
+ z-index: 240;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 4px 0;
/* Twenty-five seasons will not fit a phone: the panel scrolls, the modal
- behind it does not have to. */
- max-height: min(320px, 50vh);
+ behind it does not have to. The inline max-height narrows this further to
+ whatever room the trigger actually has. */
+ max-height: 320px;
overflow-y: auto;
}
.video-season-option {
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`
<div class="video-season-menu" ref=${ref}>
<button class="video-season-trigger" aria-haspopup="listbox"
aria-expanded=${open ? 'true' : 'false'}
- onClick=${() => setOpen((o) => !o)}>
+ onClick=${toggle}>
<span class="video-season-current">
${label(selected)}${selectedYear ? ` · ${selectedYear}` : ''}
</span>
<${Icon} name="chevron" cls="video-season-caret ${open ? 'flip' : ''}" />
</button>
- ${open && html`
- <div class="video-season-options" role="listbox">
+ ${open && pos && html`
+ <div class="video-season-options" role="listbox" style=${pos}>
${seasons.map((s) => html`
<button key=${s.season} role="option"
aria-selected=${s.season === selected ? 'true' : 'false'}
@@ -675,7 +719,7 @@ function VideoDetailModal({
<div class="video-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
}}>
- <div class="video-detail">
+ <div class="video-detail ${showMultiSeason ? 'video-detail-steady' : ''}">
<div class="video-top-bar">
<span class="video-title">${(confident && meta.title) || title}</span>
<button class="video-close" onClick=${onClose} title=${t('video.close')}>