# Floating up/down jump-scroll buttons for the playlists view ## User request (verbatim) > take a look at the main view of the popup window. I like how it is now, but I want a way to easily scroll between the playlists. perhaps two floating circular "up" and "down" buttons could scroll the window to the position of the next (or previous) playlist start. the "up" button should be greyed out if at the top, and the "down" button should be greyed out if past the starting point of the last playlist. the buttons should be in an expected location (maybe the lower-right corner), but we may need to do something to make sure that they don't cover the action buttons (ie: on the last few videos in the list). one possibility might be to add a margin at the bottom, so that the user can scroll past the bottom, so that the last video is above the "up" button". what do you think of this plan? Follow-up clarification (via AskUserQuestion): confirmed that both buttons should disable entirely when there's nothing meaningful to scroll to (single playlist, or a short list that already fits without scrolling), not just at the top/bottom extremes. ## What was done Added two floating circular buttons (↑/↓) fixed to the lower-right corner of the popup, visible only on the `playlists` view, that jump-scroll to the start of the previous/next playlist. ### Files changed - `popup/popup.html` — added a `.playlists-scroll-spacer` div at the end of `.playlists-container` (reserves scroll room past the last playlist), and a `.playlist-jump-buttons` container with the two buttons, bound via `x-bind` (CSP-compliant, no inline expressions). - `popup/popup.js` — added `playlistsScrollTop` reactive property, a `scroll` listener registered in `init()`, `getPlaylistBoundaryOffsets()`/`hasScrollableOverflow()` helpers, `scrollToNextPlaylistBoundary()`/`scrollToPreviousPlaylistBoundary()` methods, and the `playlistJumpButtons`/`scrollUpButton`/`scrollDownButton` CSP-compliant bindings. - `popup/popup.css` — `.playlist-jump-buttons` (fixed position, bottom-right), `.jump-btn` (36px circle, reusing `.back-btn-arrow`'s sizing and the existing disabled-button color pattern), `.playlists-scroll-spacer` (110px reserved height). ### Bugs found and fixed during verification Verification was done by scripting a headless Chromium (Playwright) against the real `popup.html`/`popup.js`/`popup.css` with a stubbed `browser.storage.local`/`browser.tabs` API (no Firefox available in this environment) — this caught two real bugs that would not have been visible from code review alone: 1. **Wrong scroll container.** The popup's CSS (`body { overflow-y: auto; max-height: 600px }` with no constraint on `html`) triggers a standard browser quirk where the *actual* scrolling element ends up being `document.documentElement` (`document.scrollingElement`), not `document.body`. The initial implementation targeted `document.body` for `scrollTop`/`scrollTo`/the scroll listener, so clicking the buttons did nothing. Fixed by using `document.scrollingElement` / `window.scrollTo` / a `window` scroll listener instead. 2. **Frozen disabled-state reactivity.** Alpine only tracks a reactive property as an effect dependency if it was actually *read* during that effect's evaluation. Because the original `:disabled` logic short-circuited (`!canScrollPlaylists() || playlistsScrollTop <= 0`) and `canScrollPlaylists()` returned `false` on first mount (before playlists finish loading asynchronously), `playlistsScrollTop` was never read on that first run — permanently freezing the binding with no tracked dependency, so it never updated on scroll. Fixed by reading `this.playlistsScrollTop` and `this.playlistsForDisplay.length` unconditionally at the top of both `:disabled` functions, before any early return. 3. **Offset double-counting.** The original `getPlaylistBoundaryOffsets()` formula (`rect.top - scrollerRect.top + scroller.scrollTop`) assumed the scrolling element's own `getBoundingClientRect().top` stays `0` regardless of scroll position. In reality, for the root scrolling element, `rect.top === -scrollTop`, so the formula double-counted the scroll offset, causing "jump to next playlist" to overshoot cumulatively on repeated clicks. Fixed to the simpler, correct `el.getBoundingClientRect().top + scroller.scrollTop`. 4. **Down button never disabling at max scroll.** The last playlist's heading offset can exceed the maximum reachable `scrollTop` (there isn't enough content below it to scroll that far), so comparing directly against that offset meant the down button never disabled even at the scroll ceiling. Fixed by clamping the target to `Math.min(lastOffset, maxScrollTop)` before comparing. ### Verification performed - Confirmed via the `verify-csp` skill that no CSP violations were introduced (only a pre-existing commented-out line matched the violation pattern). - Scripted Playwright/Chromium runs against the live popup with mocked `browser.*` APIs, covering: up/down disabled state at top and bottom, clicking through all playlist boundaries, confirming the last playlist's video action buttons fully clear the fixed buttons once scrolled to the max, and single-playlist / short-list / zero-playlist edge cases (both buttons stay disabled). - Visual screenshots confirmed the floating buttons render correctly in the lower-right corner and grey out as expected. ## Follow-up: cursor style on disabled buttons ### User request (verbatim) > I like it, but in the "disabled" case, remove the "not allowed" mouse cursor. just leave it as a plain, no-action cursor pointer. ### What was done Changed `.jump-btn:disabled` in `popup/popup.css` from `cursor: not-allowed` to `cursor: default`, so hovering a greyed-out button shows the plain arrow cursor instead of the circle-slash "not allowed" icon.