aboutsummaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-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
-rw-r--r--packages/meshbay-hub/tests/test_season_panel_placement.py163
-rw-r--r--packages/meshbay-hub/tests/test_video_detail_measured.py306
4 files changed, 449 insertions, 117 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')}>
diff --git a/packages/meshbay-hub/tests/test_season_panel_placement.py b/packages/meshbay-hub/tests/test_season_panel_placement.py
new file mode 100644
index 0000000..8c04ea7
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_season_panel_placement.py
@@ -0,0 +1,163 @@
+"""
+Where the season menu's panel opens, as arithmetic.
+
+The panel used to be an absolutely positioned child of the picker, and the
+detail modal clips — `overflow: hidden`, for its rounded corners. A show with a
+dozen seasons opens a panel taller than the room left under the picker on
+anything but a tall window, and the seasons past the cut then sat outside the
+modal where no scroll could reach them. Measured live at a 740 px viewport: the
+panel wanted 320 px and had 288 px.
+
+So it is `position: fixed` now, and `placeSeasonPanel()` decides where. That
+makes it the only part of this menu the stylesheet does not determine, and
+`test_video_detail_measured.py` says so where the rest of the modal is
+measured. This is the half a browser cannot be pointed at: pure geometry over a
+rect and a window height, which is exactly what a node test is for.
+
+The function is read out of `video-app.js` rather than duplicated — a copy
+would keep passing after the original changed.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "video-app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not APP.exists(),
+ reason="node or the SPA sources are not available")
+
+# The three constants and the function, taken as written. Anchored on the
+# `const SEASON_PANEL_MAX` declaration through the end of the function, so a
+# rename or a move fails loudly here rather than silently testing nothing.
+BLOCK = re.compile(
+ r"^const SEASON_PANEL_MAX = .*?^function placeSeasonPanel\(el\) \{.*?^\}",
+ re.M | re.S)
+
+
+@pytest.fixture(scope="module")
+def source():
+ m = BLOCK.search(APP.read_text())
+ assert m, ("placeSeasonPanel is no longer where this test reads it from — "
+ "the season menu's placement is untested until this is fixed")
+ return m.group(0)
+
+
+def _place(tmp_path, source, *, top, bottom, left=40, width=300, inner_height=740):
+ """Run the real function against one trigger rectangle."""
+ script = tmp_path / "case.js"
+ script.write_text(f"""
+ globalThis.window = {{ innerHeight: {inner_height} }};
+ {source}
+ const el = {{ getBoundingClientRect: () => ({{
+ top: {top}, bottom: {bottom}, left: {left}, width: {width} }}) }};
+ console.log(JSON.stringify(placeSeasonPanel(el)));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+def _px(value):
+ assert value.endswith("px"), f"expected a px length, got {value!r}"
+ return float(value[:-2])
+
+
+def test_it_opens_downwards_when_the_room_is_below(tmp_path, source):
+ """The ordinary case: the picker sits near the top of the modal."""
+ pos = _place(tmp_path, source, top=300, bottom=337)
+
+ assert pos["bottom"] == "auto"
+ assert _px(pos["top"]) == 341, "the panel should start just under the trigger"
+ assert _px(pos["maxHeight"]) == 320, (
+ "740 - 337 - 12 = 391 px of room below, so the panel takes its full "
+ f"320 px and got {pos['maxHeight']}")
+
+
+def test_it_flips_above_the_trigger_when_the_room_is_up_there(tmp_path, source):
+ """A short window, or a picker pushed down the modal.
+
+ Absolutely positioned this was the case that lost seasons off the bottom
+ of the modal with nothing able to scroll to them.
+ """
+ pos = _place(tmp_path, source, top=600, bottom=637)
+
+ assert pos["top"] == "auto"
+ assert _px(pos["bottom"]) == 740 - 600 + 4, (
+ "the panel should be pinned to just above the trigger")
+ assert _px(pos["maxHeight"]) == 320, (
+ "588 px of room above against 91 below — it should open upwards at "
+ f"full height, and got {pos['maxHeight']}")
+
+
+def test_it_never_asks_for_more_room_than_it_has(tmp_path, source):
+ """The defect itself: 320 px of seasons into the 288 px that were free.
+
+ A picker near the top of a short window — downwards is still the better
+ side, but there is nowhere near 320 px of it.
+ """
+ pos = _place(tmp_path, source, top=100, bottom=137, inner_height=300)
+
+ assert pos["bottom"] == "auto", "151 px below against 88 above"
+ assert _px(pos["maxHeight"]) == 300 - 137 - 12, (
+ "the panel must take the room it actually has, and asked for "
+ f"{pos['maxHeight']}")
+
+
+def test_it_picks_the_side_with_the_room_on_it(tmp_path, source):
+ """Not "below unless below is impossible": whichever side is roomier.
+
+ A picker two thirds of the way down a short window has 188 px under it and
+ 288 px over it. Opening downwards there would fit, and would still be the
+ smaller menu.
+ """
+ pos = _place(tmp_path, source, top=300, bottom=337, inner_height=537)
+
+ assert pos["top"] == "auto", "288 px above against 188 below"
+ assert _px(pos["maxHeight"]) == 288
+
+
+def test_a_cramped_window_still_gets_a_usable_panel(tmp_path, source):
+ """Squeezed from both sides, it keeps a floor and scrolls inside it.
+
+ Better a panel that overhangs a little and can be scrolled than one
+ collapsed to a couple of rows — this is a phone in landscape.
+ """
+ pos = _place(tmp_path, source, top=150, bottom=187, inner_height=340)
+
+ assert _px(pos["maxHeight"]) == 141, (
+ "153 px below and 138 above, so it opens downwards into the room it "
+ f"has, and got {pos['maxHeight']}")
+
+ tighter = _place(tmp_path, source, top=90, bottom=127, inner_height=180)
+ assert _px(tighter["maxHeight"]) == 120, (
+ "41 px below and 78 above is under the floor either way, so the floor "
+ f"applies, and got {tighter['maxHeight']}")
+
+
+def test_it_takes_the_trigger_s_own_left_and_width(tmp_path, source):
+ """Fixed to the viewport, it no longer inherits the picker's box — the
+ two must be lined up by hand or the menu hangs off its own trigger."""
+ pos = _place(tmp_path, source, top=300, bottom=337, left=64, width=412)
+
+ assert pos["left"] == "64px" and pos["width"] == "412px"
+
+
+def test_no_element_means_no_position(tmp_path, source):
+ """Called before the picker is mounted, it must not invent coordinates —
+ the panel renders only once there is a `pos` to render it at."""
+ script = tmp_path / "none.js"
+ script.write_text(f"""
+ globalThis.window = {{ innerHeight: 740 }};
+ {source}
+ console.log(JSON.stringify(placeSeasonPanel(null)));
+ """)
+ proc = subprocess.run(["node", str(script)], capture_output=True, text=True)
+ assert proc.returncode == 0, proc.stderr
+ assert json.loads(proc.stdout) is None
diff --git a/packages/meshbay-hub/tests/test_video_detail_measured.py b/packages/meshbay-hub/tests/test_video_detail_measured.py
index e72af0f..1ac8586 100644
--- a/packages/meshbay-hub/tests/test_video_detail_measured.py
+++ b/packages/meshbay-hub/tests/test_video_detail_measured.py
@@ -1,33 +1,45 @@
"""
-The show detail modal, measured: the season picker must not move.
+The show detail modal, measured: nothing above the episode list may move.
-Reported against a real library. Three faults, one complaint — opening a
-different season of the same show made the whole modal jump:
+Reported against a real library, three times running, because the jump had
+more than one cause and fixing one left the others. Opening a different season
+of the same show moved the season picker and everything under it, so the
+control just clicked was no longer under the pointer.
- * a season with a twelve-line synopsis and one with a two-line synopsis put
- the season picker, and every episode under it, ten lines apart. The
- synopsis is now a constant three lines for a multi-season show — clamped
- from above and pinned from below to the same number, so the picker does
- not move at all. Five was tried first and left a short synopsis sitting
- over three blank lines;
- * the cast line was however many lines the cast was long;
- * the season picker itself was a row of pills with `overflow-x: auto`, so a
- show with a dozen seasons hid most of them behind a horizontal scrollbar
- that is close to unusable on a phone.
+ * The synopsis was however many lines TMDB wrote — two for one season,
+ twelve for the next.
+ * The cast line was however long the cast was.
+ * The episode list is however many episodes the season has, and it lived in
+ the same scroll box as everything else, so a thirteen-episode season made
+ a taller modal than a six-episode one.
+ * `.video-overlay` centres its child, so that taller modal also started
+ *higher* up the screen: the whole dialog rose.
+ * A season that overflowed showed a scrollbar, which narrowed the content by
+ a scrollbar's width, which re-wrapped the file path at the top and shifted
+ everything below it by a line.
+
+The last three are the ones a fixed-height synopsis cannot help with, and they
+are why this file measures the modal's own rectangle and not just its text.
The fixes are geometric, so the assertions are rectangles rather than
declarations, in the manner `test_layout_measured.py` established: reading
`-webkit-line-clamp: 2` out of the stylesheet says nothing about where the
thing below it lands.
-The markup here is what `video-app.js`'s `OverviewText` and `SeasonMenu`
-render, class for class — `clamped` always while collapsed, `has-more` only
-when the text actually overflows (measured in the browser, not counted), and
-`reserved` only for a multi-season show, which is the only place a season can
-change underneath the picker.
+The season menu's own panel is not here: it is `position: fixed`, placed by
+`placeSeasonPanel()` rather than by the stylesheet, and
+`test_season_panel_placement.py` holds that arithmetic to the room it is
+given.
+
+The markup is what `video-app.js` renders, class for class — `clamped` always
+while collapsed, `has-more` only when the text actually overflows (measured in
+the browser, not counted), and `reserved`/`video-detail-steady` only for a
+multi-season show, which is the only place a season can change underneath the
+picker.
"""
import json
+import re
import shutil
import subprocess
import textwrap
@@ -56,49 +68,65 @@ LONG_SYNOPSIS = (
"ever a secret or merely something no one had asked about."
)
-SHORT_CAST = "A Performer, Another Performer"
-LONG_CAST = ", ".join(f"Performer Number {n}" for n in range(1, 11))
+CAST = ", ".join(f"Performer Number {n}" for n in range(1, 11))
+
+# A path long enough to sit near the wrap boundary, which is what made a
+# scrollbar's width visible as a one-line shift of everything below it.
+SOURCE = ("File: shows/A.Show.With.Several.Seasons/S01/"
+ "A.Show.With.Several.Seasons.S01E01.FRENCH.720p.BluRay.x264-grp.mkv")
+
+# The modal is `min(calc(100vh - 100px), 760px)` tall and top-anchored 60px
+# down, so a block of this height holds one whole overlay at the probe's 740px
+# iframe height with room to spare.
+BLOCK_H = 800
+
+
+def _episodes(n):
+ return "".join(f"""
+ <button class="video-episode-row">
+ <span class="video-episode-label">S05E{i:02d} &middot; An Episode</span>
+ <span class="video-episode-meta">44min 1280x720</span>
+ </button>""" for i in range(1, n + 1))
-SEASONS = [(0, "Specials", "4 episodes"),
- *((n, f"Season {n}", "22 episodes") for n in range(1, 13))]
+def _detail(block_id, synopsis, *, episodes=6, steady=True, has_more=True):
+ """One `.video-detail` inside a real `.video-overlay`, as it is rendered.
-def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open=False):
- """One `.video-detail` as VideoDetailModal renders it for a show."""
+ The overlay matters: it is what centres the modal, and the centring is
+ half of why the dialog rose. `position: absolute` (the rule says `fixed`)
+ keeps `inset: 0` but scopes it to the sized block below, so several of
+ these can be measured in one pass.
+
+ Each block gets a whole-pixel height. Stacked in normal flow they would
+ start at fractional offsets — a modal's content height is not an integer
+ number of device pixels — and two identical layouts an eighth of a pixel
+ apart round to tops one pixel apart, which reads as a defect in the thing
+ being measured and is not one.
+ """
overview_cls = " ".join(
["video-detail-overview", "clamped"]
+ (["has-more"] if has_more else [])
- + (["reserved"] if reserved else []))
+ + (["reserved"] if steady else []))
toggle = ('<button class="video-overview-toggle">… Read more</button>'
if has_more else "")
- options = "".join(
- f'<button class="video-season-option{" active" if n == 5 else ""}">'
- f'<span class="video-season-option-name">{name}</span>'
- f'<span class="video-season-option-count">{count}</span></button>'
- for n, name, count in SEASONS)
- panel = (f'<div class="video-season-options" role="listbox">{options}</div>'
- if menu_open else "")
- # Each block sits in a container of whole-pixel height. Stacked in normal
- # flow they start at fractional offsets — a modal's own content height is
- # not an integer number of device pixels — and two identical layouts an
- # eighth of a pixel apart round to tops one pixel apart, which looks like
- # a defect in the thing being measured and is not one.
return f"""
- <div style="position: relative; height: 760px">
- <div id="{block_id}" class="video-detail">
+ <div style="position: relative; height: {BLOCK_H}px">
+ <div id="{block_id}-ov" class="video-overlay" style="position: absolute">
+ <div id="{block_id}" class="video-detail{' video-detail-steady' if steady else ''}">
<div class="video-top-bar">
<span class="video-title">A Show With Several Seasons</span>
<button class="video-close">x</button>
</div>
<div class="video-detail-body">
- <p class="video-detail-source">File: shows/S05/e01.mkv &middot; TMDB #0000</p>
+ <p class="video-detail-source">{SOURCE}
+ <span class="video-detail-tmdbref"> &middot; TMDB #0000</span></p>
<div class="video-overview-wrap">
<p class="{overview_cls}">{toggle}{synopsis}</p>
</div>
<p class="video-detail-facts">
&#9733; 8.4 &middot; Mystery, Drama &middot; 1993 &middot; Director: A Creator
</p>
- <p class="video-detail-cast">{cast}</p>
+ <p class="video-detail-cast">{CAST}</p>
<div class="video-admin-actions">
<button class="admin-btn video-fix-match">Fix match&hellip;</button>
<button class="admin-btn">Re-match</button>
@@ -108,45 +136,45 @@ def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open
<span class="video-season-current">Season 5 &middot; 1997</span>
<svg class="icon video-season-caret" viewBox="0 0 24 24"></svg>
</button>
- {panel}
</div>
<div class="video-season-list">
- <div class="video-season">
- <button class="video-episode-row">
- <span class="video-episode-label">S05E01 &middot; An Episode</span>
- <span class="video-episode-meta">44min 1280x720</span>
- </button>
- </div>
+ <div class="video-season">{_episodes(episodes)}</div>
</div>
</div>
</div>
</div>
+ </div>
"""
-FRAGMENT = textwrap.dedent(f"""
+FRAGMENT = textwrap.dedent("""
<!-- One line of the same text at the same size: every height below is
asserted in these, not in pixels pinned to a font stack. -->
<div id="oneline" class="video-detail"><div class="video-detail-body">
<div class="video-overview-wrap"><p class="video-detail-overview">One</p></div>
- <p class="video-detail-cast">{SHORT_CAST}</p>
+ <p class="video-detail-cast">A Performer, Another Performer</p>
</div></div>
""") + \
- _detail("long", LONG_SYNOPSIS, LONG_CAST) + \
- _detail("short", SHORT_SYNOPSIS, LONG_CAST, has_more=False) + \
- _detail("movie", SHORT_SYNOPSIS, SHORT_CAST, reserved=False, has_more=False) + \
- _detail("open", LONG_SYNOPSIS, LONG_CAST, menu_open=True)
+ _detail("long", LONG_SYNOPSIS) + \
+ _detail("short", SHORT_SYNOPSIS, has_more=False) + \
+ _detail("many", SHORT_SYNOPSIS, episodes=24, has_more=False) + \
+ _detail("movie", SHORT_SYNOPSIS, episodes=1, steady=False, has_more=False)
WIDTHS = [320, 360, 412, 768, 1024]
+
+# The three blocks whose season picker must land on the same pixel: a long and
+# a short synopsis, and a short and a long season.
+STEADY = ["long", "short", "many"]
+
SELECTORS = [
"#oneline .video-detail-overview", "#oneline .video-detail-cast",
- "#long .video-detail-overview", "#long .video-detail-cast",
- "#long .video-season-menu", "#long .video-detail-body",
- "#long .video-overview-toggle",
- "#short .video-detail-overview",
- "#short .video-season-menu", "#short .video-detail-body",
- "#movie .video-detail-overview",
- "#open .video-season-options", "#open.video-detail",
+ "#long .video-overview-toggle", "#long .video-detail-cast",
+ "#movie.video-detail", "#movie .video-detail-overview",
+] + [
+ sel.format(b) for b in STEADY for sel in
+ ("#{0}.video-detail", "#{0}-ov.video-overlay", "#{0} .video-detail-body",
+ "#{0} .video-season-menu", "#{0} .video-detail-overview",
+ "#{0} .video-detail-source", "#{0} .video-season-list")
]
@@ -176,33 +204,108 @@ def _line(measured, width):
return _box(measured, width, "#oneline .video-detail-overview")["height"]
+def _picker_offset(measured, width, block):
+ """How far the season picker sits inside its own modal body."""
+ return (_box(measured, width, f"#{block} .video-season-menu")["top"]
+ - _box(measured, width, f"#{block} .video-detail-body")["top"])
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_season_picker_does_not_move_between_seasons(measured, width):
+ """The complaint itself, over every way one season differs from another.
+
+ `long` and `short` differ only in the length of the synopsis; `short` and
+ `many` differ only in the number of episodes. Both are things a season
+ change varies, and neither may move the picker by a pixel.
+ """
+ offsets = {b: _picker_offset(measured, width, b) for b in STEADY}
+ assert len(set(offsets.values())) == 1, (
+ f"at {width} px the season picker sits at {offsets} px inside its own "
+ "modal body — a jump on every season change")
+
+
@pytest.mark.parametrize("width", WIDTHS)
-def test_the_season_picker_does_not_move_with_the_synopsis(measured, width):
- """The complaint itself.
+def test_the_modal_itself_does_not_move_or_resize_between_seasons(measured, width):
+ """The half a fixed-height synopsis cannot reach.
- The two blocks differ in one thing only, which is the one thing that
- changes when a season is selected: the synopsis. Everything above the
- picker is show-level and identical between them. One is a single line, the
- other twelve — the widest gap a season change can produce — and the answer
- must be the same pixel, not a narrow band. Measured from each modal's own
- body, since the blocks are stacked down the page.
+ `.video-overlay` centres its child, so a season with four times the
+ episodes built a taller modal that started higher up the screen — the
+ title bar and the close button moved too, not just the picker.
+ Top-anchored and one height for every season, the modal is the same
+ rectangle whatever the season holds.
"""
- def offset(block):
- return (_box(measured, width, f"#{block} .video-season-menu")["top"]
- - _box(measured, width, f"#{block} .video-detail-body")["top"])
+ boxes = {b: _box(measured, width, f"#{b}.video-detail") for b in STEADY}
+ # Against each block's own overlay: the blocks are stacked down the page,
+ # so absolute tops differ by construction and say nothing.
+ tops = {b: box["top"] - _box(measured, width, f"#{b}-ov.video-overlay")["top"]
+ for b, box in boxes.items()}
+ heights = {b: box["height"] for b, box in boxes.items()}
+ assert len(set(tops.values())) == 1, (
+ f"at {width} px the modal's top edge is at {tops} — the dialog moves "
+ "under the pointer when the season does")
+ assert len(set(heights.values())) == 1, (
+ f"at {width} px the modal is {heights} px tall — one season's episode "
+ "count is deciding the size of the dialog")
- long_off, short_off = offset("long"), offset("short")
- assert long_off == short_off, (
- f"at {width} px the season picker sits {long_off} px down for a long "
- f"synopsis and {short_off} px down for a short one — a "
- f"{abs(long_off - short_off)} px jump on every season change")
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_only_the_episode_list_absorbs_the_episode_count(measured, width):
+ """Where the difference is supposed to go instead.
+
+ A twenty-four-episode season has to overflow something. It must be the
+ list, which scrolls inside a modal of fixed size, and not the modal.
+ """
+ for block in STEADY:
+ lst = _box(measured, width, f"#{block} .video-season-list")
+ modal = _box(measured, width, f"#{block}.video-detail")
+ assert lst["top"] + lst["height"] <= modal["top"] + modal["height"] + 1, (
+ f"the {block} episode list runs past the bottom of its own modal "
+ f"at {width} px — it is not the thing scrolling")
+
+
+@pytest.mark.parametrize("width", WIDTHS)
+def test_the_file_path_wraps_the_same_way_for_every_season(measured, width):
+ """One line of the jump, from a different direction.
+
+ This is the assertion, but not the whole guard: see
+ `test_the_episode_list_reserves_its_scrollbar` below for why the scrollbar
+ half of it cannot be measured here.
+ """
+ heights = {b: _box(measured, width, f"#{b} .video-detail-source")["height"]
+ for b in STEADY}
+ assert len(set(heights.values())) == 1, (
+ f"at {width} px the file path is {heights} px tall depending on the "
+ "season — something above it is changing how it wraps")
+
+
+def test_the_episode_list_reserves_its_scrollbar():
+ """A declaration, deliberately, and the one place in this file that is.
+
+ A season that overflows its list draws a scrollbar; one that does not,
+ does not. That is a scrollbar's width of content, which re-wraps the file
+ path above and shifts everything below by a line — the same jump by a
+ different route, and it is visible in the reported screenshots.
+
+ It cannot be measured here. Headless Chrome gives the probe overlay
+ scrollbars, which take no width, so the geometric test above passes with
+ or without the rule. Reading the declaration is worth less than a
+ rectangle — `test_layout_responsive.py` says so at length — but it is
+ worth more than a test that cannot fail.
+ """
+ css = (STATIC / "style.css").read_text()
+ rule = re.search(
+ r"\.video-detail\.video-detail-steady \.video-season-list \{([^}]*)\}", css)
+ assert rule, "the steady episode-list rule is gone"
+ assert "scrollbar-gutter: stable" in rule.group(1), (
+ "without a reserved gutter a scrolling season is a scrollbar narrower "
+ "than a non-scrolling one, and everything above the list re-wraps")
@pytest.mark.parametrize("width", WIDTHS)
def test_the_synopsis_is_three_lines_whatever_it_says(measured, width):
"""Clamped from above and pinned from below to the same three lines."""
line = _line(measured, width)
- for block in ("long", "short"):
+ for block in STEADY:
h = _box(measured, width, f"#{block} .video-detail-overview")["height"]
assert abs(h - 3 * line) <= 4, (
f"the {block} synopsis is {h} px at {width} px, and one line is "
@@ -230,22 +333,6 @@ def test_a_read_more_link_lands_on_the_last_line(measured, width):
@pytest.mark.parametrize("width", WIDTHS)
-def test_a_short_synopsis_with_nothing_to_expand_stays_short(measured, width):
- """The spacer is scoped to `.has-more` for a reason.
-
- `overflow: hidden` makes the paragraph a block formatting context, so it
- contains the four-line-tall float and grows to it. A movie or a
- single-season show — nothing below it can change — must keep its natural
- height, with no floor under it either.
- """
- line = _line(measured, width)
- h = _box(measured, width, "#movie .video-detail-overview")["height"]
- assert h <= 2 * line + 4, (
- f"a two-line synopsis with no season under it occupies {h} px at "
- f"{width} px, which is {h / line:.1f} lines of mostly nothing")
-
-
-@pytest.mark.parametrize("width", WIDTHS)
def test_the_cast_is_two_lines_however_long_the_cast_is(measured, width):
"""Ten names and two names must give the modal the same height."""
ref = _box(measured, width, "#oneline .video-detail-cast")["height"]
@@ -256,20 +343,23 @@ def test_the_cast_is_two_lines_however_long_the_cast_is(measured, width):
@pytest.mark.parametrize("width", WIDTHS)
-def test_the_open_season_menu_stays_inside_the_modal(measured, width):
- """It replaced a horizontally scrolling pill row; it must not overflow
- sideways in turn, and thirteen seasons must not run off the bottom."""
- panel = _box(measured, width, "#open .video-season-options")
- modal = _box(measured, width, "#open.video-detail")
- assert panel["offLeft"] == 0 and panel["offRight"] == 0, (
- f"the season menu hangs {panel['offLeft']} px off the left and "
- f"{panel['offRight']} px off the right of a {width} px screen")
- assert panel["left"] >= modal["left"] and panel["right"] <= modal["right"], (
- f"the season menu ({panel['left']}..{panel['right']}) is wider than "
- f"the modal it belongs to ({modal['left']}..{modal['right']})")
- assert panel["height"] <= 320, (
- f"thirteen seasons make a {panel['height']} px menu — it is meant to "
- "scroll inside itself, not to become the page")
+def test_a_movie_is_not_given_a_show_sized_modal(measured, width):
+ """None of this is scoped to every video.
+
+ A movie and a single-season show have no season to switch to. A fixed
+ height and a floor under the synopsis would buy them nothing but empty
+ space, so neither is applied and the modal is content-sized as before.
+ """
+ line = _line(measured, width)
+ h = _box(measured, width, "#movie .video-detail-overview")["height"]
+ assert h <= 2 * line + 4, (
+ f"a one-line synopsis with no season under it occupies {h} px at "
+ f"{width} px, which is {h / line:.1f} lines of mostly nothing")
+ movie = _box(measured, width, "#movie.video-detail")["height"]
+ steady = _box(measured, width, "#short.video-detail")["height"]
+ assert movie < steady, (
+ f"a one-episode movie modal is {movie} px against a show's {steady} px "
+ "— the show-only fixed height has escaped its scope")
@pytest.mark.parametrize("width", [320, 360, 412])