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 --- docs/mediacenter.md | 43 ++- .../meshbay-hub/src/meshbay_hub/static/style.css | 45 ++- .../src/meshbay_hub/static/video-app.js | 52 +++- .../tests/test_season_panel_placement.py | 163 +++++++++++ .../tests/test_video_detail_measured.py | 312 +++++++++++++-------- 5 files changed, 487 insertions(+), 128 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_season_panel_placement.py diff --git a/docs/mediacenter.md b/docs/mediacenter.md index b4526aa..e983ef9 100644 --- a/docs/mediacenter.md +++ b/docs/mediacenter.md @@ -827,6 +827,26 @@ episode list are, so the thing just clicked was no longer under the pointer. - **The cast line was however long the cast was.** Clamped to two lines, ellipsised at the end of the second. Nothing to expand to here, so this one is a plain `-webkit-line-clamp`. +- **The episode list is however many episodes the season has**, and that is + the half neither clamp can 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 — and `.video-overlay` centres its child, so the + taller modal *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 `.video-season-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 season that scrolls also draws a scrollbar where one that does not draws + none, which is a scrollbar's width of content and re-wrapped the file path + above it; `scrollbar-gutter: stable` reserves it either way. +- **The season menu's panel is `position: fixed`.** As an absolutely + positioned child it was clipped by the modal's own `overflow: hidden` + whenever the seasons outran the room under the picker — measured at a 740 px + viewport, a thirteen-season panel wanted 320 px and had 288, and the rest sat + outside the modal where no scroll could reach it. `placeSeasonPanel()` takes + the trigger's rect and the window height, picks whichever side has more room, + and caps the panel to it. It is the only part of this menu the stylesheet + does not decide, so it has its own test. - **The season tab bar scrolled sideways.** A show with a dozen seasons hid most of them behind a horizontal scrollbar — unusable on a phone, and ugly everywhere. `SeasonMenu` replaces `SeasonTabs`: one trigger reading @@ -846,14 +866,21 @@ keeps its `null` until `TMDB_META_TTL_SECS` expires or an operator re-matches. Measured, not read out of the stylesheet (`test_video_detail_measured.py`, via the `layout_probe.py` harness): the season picker's offset inside its own -modal body is the same pixel for a one-line and a twelve-line synopsis, at -every width from 320 px up; the clamped synopsis is three line-heights either -way and the cast at most two; the read-more link lands on the third line; an -unreserved short synopsis has no floor under it; and the open menu fits inside -the modal at 320 px. Each measured block sits in a whole-pixel-height -container — stacked in flow they start at fractional offsets, 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. +modal body is the same pixel for a one-line and a twelve-line synopsis *and* +for a six- and a twenty-four-episode season, at every width from 320 px up; +the modal's own top and height are the same for all three; the episode list +never runs past the bottom of its modal; the clamped synopsis is three +line-heights either way and the cast at most two; the read-more link lands on +the third line; and a movie gets neither the floor nor the fixed height. Each +measured block sits inside a real `.video-overlay` — the centring is half the +defect — in a whole-pixel-height container, since stacked in flow they start at +fractional offsets 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. `test_season_panel_placement.py` runs `placeSeasonPanel()` in +node over a rect and a window height: down, flipped up, capped to the room +available, floored when cramped. Two things there are declarations rather than +rectangles, and say so in their own docstrings: `scrollbar-gutter`, because +headless Chrome gives the probe zero-width overlay scrollbars. `test_tmdb_show_director.py` covers the credit. ## 11. Acceptance before shipping 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`
- ${open && html` -
+ ${open && pos && html` +
${seasons.map((s) => html` """ 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. + 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. -def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open=False): - """One `.video-detail` as VideoDetailModal renders it for a show.""" + 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 = ('' if has_more else "") - options = "".join( - f'' - for n, name, count in SEASONS) - panel = (f'
{options}
' - 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""" -
-
+
+
+
A Show With Several Seasons
-

File: shows/S05/e01.mkv · TMDB #0000

+

{SOURCE} + · TMDB #0000

{toggle}{synopsis}

★ 8.4 · Mystery, Drama · 1993 · Director: A Creator

-

{cast}

+

{CAST}

@@ -108,45 +136,45 @@ def _detail(block_id, synopsis, cast, *, reserved=True, has_more=True, menu_open Season 5 · 1997 - {panel}
-
- -
+
{_episodes(episodes)}
+
""" -FRAGMENT = textwrap.dedent(f""" +FRAGMENT = textwrap.dedent("""

One

-

{SHORT_CAST}

+

A Performer, Another Performer

""") + \ - _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_modal_itself_does_not_move_or_resize_between_seasons(measured, width): + """The half a fixed-height synopsis cannot reach. + + `.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. + """ + 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") + + @pytest.mark.parametrize("width", WIDTHS) -def test_the_season_picker_does_not_move_with_the_synopsis(measured, width): - """The complaint itself. - - 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. +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. """ - def offset(block): - return (_box(measured, width, f"#{block} .video-season-menu")["top"] - - _box(measured, width, f"#{block} .video-detail-body")["top"]) + 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. - 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") + 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 " @@ -229,22 +332,6 @@ def test_a_read_more_link_lands_on_the_last_line(measured, width): f"{link['offRight']} px of the read-more link is off a {width} px screen") -@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.""" @@ -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]) -- cgit v1.2.3