/** * Per-route scroll memory for the app's persistent scroll containers. * * The shell keeps its scrollers alive across navigation on purpose: the root * layout, the home page and the library layout each own a * `flex-1 overflow-y-auto` box that outlives the route rendered inside it. That * is what makes the bottom UI a flex sibling rather than a measured overlay — * but it also means the *element* never remounts, so its `scrollTop` survives a * route change and the next page opens part-way down. * * SvelteKit's own scroll restoration cannot help here: it saves and restores * `window` scroll, and in this app the window never scrolls at all. * * So each container gets its own memory, which reproduces normal browser * behaviour: * * - **forward** (link/goto/form) — a fresh visit, always lands at the top; * - **popstate** (hardware/gesture Back or Forward) — restores the offset the * route was left at, so Back out of a detail page returns you to your place * in the list rather than to the top of it; * - **enter** (initial load) — left alone; there is nothing to leak yet. * * The decision is pure and lives here so it can be unit-tested without a DOM; * `scrollContainer.svelte.ts` is the thin action that applies it. * * TRACES: UR-054 | DR-156 */ /** How a navigation should affect a persistent scroll container. */ export type NavKind = "enter" | "popstate" | "forward"; /** What to do with the container once the new route has rendered. */ export type ScrollAction = { kind: "reset" } | { kind: "restore"; top: number } | { kind: "none" }; /** * Collapse SvelteKit's navigation types into the three cases that matter. * * `enter` is the initial load. `popstate` is a Back/Forward gesture. Everything * else — `link`, `goto`, `form` — is a forward move into a new page. */ export function classifyNavigation(nav: { type?: string | null }): NavKind { if (nav.type === "enter") return "enter"; if (nav.type === "popstate") return "popstate"; return "forward"; } /** * Remembers the offset each route was left at, for one scroll container. * * One instance per container: the root scroller, the home scroller and the * library scroller hold different content for the same URL, so a shared map * would restore one container's offset into another. */ export class ScrollMemory { #offsets = new Map(); /** Record where `key` was scrolled to, before we navigate away from it. */ save(key: string, top: number): void { this.#offsets.set(key, Math.max(0, top)); } /** * Decide what the container should do on arriving at `key`. * * Note this does not consume the saved offset: a route can be returned to * more than once, and each Back should restore the same place. */ decide(key: string, kind: NavKind): ScrollAction { if (kind === "enter") return { kind: "none" }; if (kind === "popstate") return { kind: "restore", top: this.#offsets.get(key) ?? 0 }; return { kind: "reset" }; } /** Drop everything. Intended for tests and sign-out. */ clear(): void { this.#offsets.clear(); } } /** * The memory key for a URL. * * Path plus query: a library grid filtered by genre is a different list from * the unfiltered one, and returning to it should restore its own place. */ export function scrollKey(url: { pathname: string; search?: string }): string { return `${url.pathname}${url.search ?? ""}`; }