From 77638f7a0bf7b4f4eafdb7c1b2753fdb0db53db6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 23:46:40 +0200 Subject: [PATCH] fix(player): a link that opens the cut text is scrolled back into view Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes. --- docs/DESIGN.md | 5 +++-- frontend/src/components/PlayerModal.dom.test.tsx | 2 -- frontend/src/components/PlayerModal.tsx | 4 ++-- frontend/src/components/ui/ClampedText.dom.test.tsx | 5 +++++ frontend/src/components/ui/ClampedText.tsx | 12 +++++++++++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1eb0827a..b012c01c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -538,7 +538,8 @@ of a full-width panel and nothing on the right. "Play from the beginning", and its picture carries the cards' progress bar. - **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch); Previous / Next live in the panel (its transport row, or the shut panel's strip) and on Shift+← / →, with no strips beside the card. Close is - an icon in the card's top-right corner. Wide, the panel shows the description outright, clamped to three + an icon at the top right of the panel (wide: its head, or its strip) and at the end of the title row + under the video (stacked). Wide, the panel shows the description outright, clamped to three lines with a More (`ClampedText`; a Tab onto a link below the cut opens it), and collapses to a 2.75rem strip of close, expand and Previous / Next, kept shut as the queue steps on and open again on a new open. Stacked, the description opens from an ⓘ, because a hover popover never opens on a tap (measured @@ -726,6 +727,6 @@ of a full-width panel and nothing on the right. | 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine | | 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it | | 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" | -| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon in the card's corner; the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard | +| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard | | 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end | | 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays | diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index a2f9b1f5..66fc8986 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -424,8 +424,6 @@ describe("PlayerModal — the description", () => { }); }); -// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts -// them off (outside the card, a 1024 window cut them and a portrait tablet lost them). // The full-height strips beside the card were a second Previous / Next that cost the video 7rem of // width (owner): the panel's transport row and Shift+← / → step on their own. describe("PlayerModal — no side strips", () => { diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index b67ad0cf..9d81e669 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -671,8 +671,8 @@ export default function PlayerModal({ // The card's width budget (its maxWidth, below): the video's, and the side panel's when wide. const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`; const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL; - // One Close, icon only, in the card's top-right corner: the wide panel's head (or its strip), the - // stacked card's title row. + // One Close, icon only: at the top right of the wide panel's head (or its strip), at the end of the + // stacked card's title row under the video. const closeButton = ( { // Chrome: 45 px), so on screen the link sits inside the box: only the text's own top tells. clamp.scrollTop = 240; vi.spyOn(screen.getByText("cut"), "getBoundingClientRect").mockReturnValue(rect(130, 150)); + // Opened, the link drops back to its place in the text, as far as the box had scrolled: in a + // scrolling panel that is out of sight, so it is brought back into view. + const reveal = vi.fn(); + screen.getByText("cut").scrollIntoView = reveal; act(() => screen.getByText("cut").focus()); expect(screen.getByText("Less").getAttribute("aria-expanded")).toBe("true"); + expect(reveal).toHaveBeenCalledWith({ block: "nearest" }); } finally { tall.mockRestore(); box.mockRestore(); diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index a6499314..192f2b1b 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -49,12 +49,22 @@ export function ClampedText({ }, [text, expanded]); // Measured from the text's top, not against the box on screen: focus scrolls the clamped box itself // to bring the link into view (overflow:hidden still scrolls), so on screen it looked "in sight". + const revealing = useRef(null); const revealFocused = (e: FocusEvent) => { const box = e.currentTarget; if (expanded || e.target === box) return; const top = box.getBoundingClientRect().top - box.scrollTop; - if (e.target.getBoundingClientRect().bottom - top > box.clientHeight + 1) setExpanded(true); + if (e.target.getBoundingClientRect().bottom - top <= box.clientHeight + 1) return; + revealing.current = e.target; + setExpanded(true); }; + // That scroll was the box's own: opened, the link drops back to its place in the text, as far below + // as the box had scrolled, and out of a scrolling panel's sight. Brought back into view. + useLayoutEffect(() => { + const el = revealing.current; + revealing.current = null; + if (expanded && el?.isConnected) el.scrollIntoView({ block: "nearest" }); + }, [expanded]); return (