fix(player): the stacked layout's minimum width no longer clips the wide one

The 36rem floor guards the stacked layout's width/height feedback loop; the
wide side-panel card has no such loop, and on a short wide window the floor
made the video taller than the card, cutting off YouTube's control bar. The
floor now applies to the stacked layout only; e2e covers a 1400x380 window.
This commit is contained in:
2026-10-06 03:19:33 +02:00
parent 6073a006b8
commit b20e9a3947
3 changed files with 24 additions and 10 deletions
+13 -1
View File
@@ -29,7 +29,8 @@ test.describe("player modal", () => {
// The video's size, in the windows the owner meets: layout bugs no DOM test can see. Each viewport
// is a real one — 1920×911 is a 1080p screen at 100% (the side-panel layout), 1097×520 the same
// screen at 175% or a small laptop at 100% (the stacked layout, on a short window).
// screen at 175% or a small laptop at 100% (the stacked layout, on a short window), 1400×380 a wide
// window made short by docked devtools (the side-panel layout, whose card does not scroll).
test.describe("player modal sizing", () => {
test.beforeEach(async ({ page }) => {
await page.route(/youtube\.com/, (route) => route.abort());
@@ -41,6 +42,7 @@ test.describe("player modal sizing", () => {
for (const [w, h] of [
[1920, 911],
[1097, 520],
[1400, 380],
] as const) {
test(`the video keeps a real size at ${w}×${h}, and maximised fills the window`, async ({
page,
@@ -54,6 +56,16 @@ test.describe("player modal sizing", () => {
const stage = await box(page, ".player-stage");
expect(stage.width).toBeGreaterThanOrEqual(Math.min(w * 0.5, 560));
expect(stage.height).toBeGreaterThan(stage.width * 0.5);
// …and all of it is in sight: its bottom edge is the YouTube control bar, and the side-panel
// card clips what overflows it (it does not scroll), the stacked card scrolls to it.
const overflow = await page
.locator(".player-stage")
.first()
.evaluate((el) => {
const card = el.parentElement!;
return el.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom;
});
expect(overflow).toBeLessThanOrEqual(0.5);
// Maximise (F): the stage is fixed to the viewport. In the side-panel layout its `self-start`
// once aligned the fixed box too, and it shrank to its content's height — 0 px, a thin line.
@@ -512,7 +512,7 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel and the side strips.
expect(card.parentElement!.style.maxWidth).toBe(
"calc(max(36rem, (100dvh - 3rem - 0px) * 16 / 9) + 20rem + 7rem)",
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
});
+10 -8
View File
@@ -104,7 +104,7 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
// a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
// The narrowest the video gets to make room for the chrome under it (see the card's maxWidth).
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem";
// The meta line's items each lead with a "·" that parts them from what stands before: the channel name
@@ -614,7 +614,7 @@ export default function PlayerModal({
};
// The card's width budget (its maxWidth, below): the video's, and the side strips' gutters.
const videoWidth = `max(${MIN_VIDEO}, (100dvh - 3rem - ${below}px) * 16 / 9)`;
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
const gutters = hasQueue ? " + 7rem" : "";
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
@@ -641,14 +641,16 @@ export default function PlayerModal({
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel.
// Never narrower than MIN_VIDEO: the chrome under the video grows as the card narrows (its text
// wraps), so on a short window fitting it was a loop that ran the card down to 2 px (a laptop
// at 100%, a 1080p screen at 175%). Past the floor the card scrolls instead.
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
// does not scroll there, always holds the whole video.
// Stacked: never narrower than MIN_VIDEO. The chrome under the video grows as the card narrows
// (its text wraps), so on a short window fitting it was a loop that ran the card down to 2 px
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
style={{
maxWidth: wide
? `calc(${videoWidth} + ${SIDE_PANEL}${gutters})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(${videoWidth}${gutters}))`,
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
}}
>
{hasQueue && (