fix(library): keep the A-Z jump strip above the mini player

The last few letters of the A-Z index sat behind the mini player and bottom
nav, where they could not be tapped — most visible on an album's track list or
the music library, since that is where audio is usually already playing.

AlphabetScrollBar sized itself as window.innerHeight minus a hardcoded
bottomGap: 5rem, 7rem or 11rem, chosen by platform and by whether the mini
player was showing. That arithmetic dates from when the mini player and bottom
nav were position: fixed overlays. They have been in-flow flex siblings below
the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is*
the top of the mini player and can simply be measured.

The guess was also short on every device with a navigation or gesture bar,
because --safe-bottom is padded inside BottomUi (DR-112) and no guess knew
about it. Measured against a 800px viewport: the strip overran the scrollport
by ~45px with the nav alone, ~18px with the mini player and ~50px in remote
mode, burying one to three letters.

The floor is now the nearest scrollable ancestor's bottom edge. That ancestor
is resolved by computed overflow-y rather than closest("main"): the root shell
scrolls in a plain div, and a miss fell back to the viewport silently, which
would reinstate the bug on any route outside /library. Observing that scroller
for resize is also what re-measures when the mini player appears, so the
component no longer subscribes to player or platform stores at all.

The floor rule is extracted to alphabetStrip.ts so it can be tested; the three
overlap cases above fail against the old arithmetic and pass against the new.

TRACES: UR-007 | DR-262 | UT-235, UT-236, UT-237
This commit is contained in:
2026-08-25 22:47:08 +02:00
parent 1d6487774c
commit 8a2b484e36
6 changed files with 191 additions and 44 deletions
@@ -1,4 +1,4 @@
<!-- TRACES: UR-007 | DR-007 -->
<!-- TRACES: UR-007 | DR-007, DR-262 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
@@ -6,8 +6,14 @@
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*
* The strip's floor is the scroll container's own bottom edge, measured — not
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
* that distinction is the whole bug (DR-262).
*/
import { stripHeightFor } from "./alphabetStrip";
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
@@ -16,43 +22,45 @@
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
let { availableLetters, onJump }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
function measure() {
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
/**
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
* by tag name: the library routes scroll in a `<main>`, but the root shell
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
* to the viewport — which is exactly the too-tall strip this replaced.
*/
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
let node = el?.parentElement ?? null;
while (node) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
node = node.parentElement;
}
return null;
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
function measure() {
if (!container) return;
const scroller = nearestScroller(container);
stripHeight = stripHeightFor({
stripTop: container.getBoundingClientRect().top,
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
viewportHeight: window.innerHeight,
});
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const scroller = nearestScroller(container);
// Observing the scroller is what makes the mini player showing or hiding
// re-measure: it is an in-flow sibling, so the scroller resizes when it
// appears. No store subscription and no platform guess needed.
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
@@ -64,15 +72,6 @@
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
@@ -6,8 +6,6 @@
import { navigateUp } from "$lib/utils/navigation";
import { currentLibrary } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { shouldShowAudioMiniPlayer } from "$lib/stores/player";
import { isAndroid } from "$lib/stores/appState";
import SearchBar from "$lib/components/common/SearchBar.svelte";
import SortButtonGroup from "$lib/components/common/SortButtonGroup.svelte";
import type { SortOption } from "$lib/components/common/SortButtonGroup.svelte";
@@ -259,11 +257,6 @@
const target = gridWrapper.querySelector(`[data-grid-index="${index}"]`);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}
// Bottom space the layout's <main> reserves for the nav / mini-player bars.
// Mirrors src/routes/library/+layout.svelte so the A-Z strip ends just above
// whichever bars are visible.
const bottomGap = $derived($shouldShowAudioMiniPlayer ? ($isAndroid ? "11rem" : "7rem") : "5rem");
</script>
<div class="space-y-6">
@@ -363,7 +356,7 @@
</div>
{#if showAlphaBar}
<div class="sticky top-2 self-start flex-shrink-0 h-fit">
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} {bottomGap} />
<AlphabetScrollBar {availableLetters} onJump={jumpToLetter} />
</div>
{/if}
</div>
@@ -0,0 +1,88 @@
import { describe, it, expect } from "vitest";
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
/**
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
* alphabet could not be tapped.
*
* The strip used to size itself against `window.innerHeight` minus a hardcoded
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
* whether the mini player was showing). Those bars stopped being fixed overlays
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
* has no relationship to the real stack — and it is short on any device with a
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
*
* The invariant every case below asserts: the strip must end at or above the
* scroller's own bottom edge, which is exactly the top of the mini player.
*
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
*/
/** 800px-tall phone viewport; the library scroller starts 120px down. */
const VIEWPORT = 800;
const STRIP_TOP = 120;
/** Measured heights of the real bottom UI, in CSS px. */
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
function bounds(bottomUiHeight: number): StripHeightInput {
return {
stripTop: STRIP_TOP,
scrollerBottom: VIEWPORT - bottomUiHeight,
viewportHeight: VIEWPORT,
};
}
describe("stripHeightFor", () => {
it("keeps the last letter above the bottom nav when nothing is playing", () => {
const input = bounds(NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("keeps the last letter above the mini player while audio plays", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("survives the taller mini player of remote mode", () => {
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("still fills the space it does have, rather than stopping short", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const available = input.scrollerBottom! - STRIP_TOP;
// Within one letter's worth of the space available (letters are ~16px).
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
});
it("falls back to the viewport when the strip has no scroll container", () => {
const height = stripHeightFor({
stripTop: STRIP_TOP,
scrollerBottom: null,
viewportHeight: VIEWPORT,
});
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
});
it("never returns a negative height when the strip is scrolled past the floor", () => {
const height = stripHeightFor({
stripTop: 900,
scrollerBottom: 600,
viewportHeight: VIEWPORT,
});
expect(height).toBe(0);
});
});
@@ -0,0 +1,54 @@
/**
* Pure geometry for the A-Z jump strip (see AlphabetScrollBar.svelte).
*
* The strip stretches from wherever it sits down to a floor, and the floor is
* the whole question: get it wrong and the tail of the alphabet renders past
* the bottom of the scroller, under the mini player / bottom nav, where it
* cannot be tapped.
*
* The floor is the scroller's own bottom edge — never the viewport's. The strip
* used to size itself as `window.innerHeight` minus a hardcoded guess at the
* bars' height (5rem/7rem/11rem by platform and mini-player visibility), which
* dates from when those bars were `position: fixed` overlays. They are in-flow
* flex siblings below the scroller now (see BottomUi.svelte), so the scroller's
* bottom edge *is* the top of the mini player, measured exactly, every frame —
* and the guess was short on every device with a navigation/gesture bar,
* because `--safe-bottom` is padded inside BottomUi and the guess never knew
* about it.
*
* Extracted from the component so the floor rule is unit-testable — the
* component only supplies measurements.
*
* TRACES: UR-007 | DR-007, DR-262
*/
/**
* Breathing room left under the last letter, in px. Mirrors the `top-2` sticky
* offset at the other end so the strip sits symmetrically in the scrollport.
*/
export const STRIP_BOTTOM_GAP = 8;
export interface StripHeightInput {
/** Viewport-relative top of the strip container (`getBoundingClientRect().top`). */
stripTop: number;
/**
* Viewport-relative bottom edge of the scroll container the strip lives in,
* or `null` when the strip has no scrollable ancestor to measure.
*/
scrollerBottom: number | null;
/** Viewport height — the fallback floor when there is no scroll container. */
viewportHeight: number;
/** Override for {@link STRIP_BOTTOM_GAP}, in px. */
gap?: number;
}
/** How tall the A-Z strip may be without running under the bottom bars. */
export function stripHeightFor({
stripTop,
scrollerBottom,
viewportHeight,
gap = STRIP_BOTTOM_GAP,
}: StripHeightInput): number {
const floor = scrollerBottom ?? viewportHeight;
return Math.max(0, floor - gap - stripTop);
}