/** * Pure layout-shell visibility rules for the app's bottom UI (mini player * stacked over the bottom nav). * * These rules used to live as inline `$derived` booleans scattered across the * root and library `+layout.svelte` files and diverged per platform/route. * * The overlap bug ("last row hidden behind the nav") is now solved * STRUCTURALLY, not by these rules: the bottom UI is rendered as an in-flow * flex child below the scroller (see BottomUi.svelte), so the scroller is * physically bounded above it and can never render behind it. There is no * measurement and no reserved padding. These functions only decide *whether* * each piece is visible on a given route. * * Keeping them pure makes the visibility contract unit-testable. * * TRACES: UR-005 | DR-009 */ import { isSearchRoute } from "$lib/utils/searchScope"; export interface BottomUiVisibilityInput { /** Current route pathname, e.g. `$page.url.pathname`. */ pathname: string; /** Whether the user is authenticated. */ isAuthenticated: boolean; } /** * The bottom nav is shown on every authenticated route except the full-screen * player and the login route. */ export function showBottomNav({ pathname, isAuthenticated, }: BottomUiVisibilityInput): boolean { return ( isAuthenticated && !pathname.startsWith("/player/") && !pathname.startsWith("/login") ); } /** * The global (root-owned) mini player is shown on every route except the * full-screen player, login, and settings. Crucially this is NOT gated on * platform or on `/library` — the root owns the mini player everywhere, so the * library route must never render its own second one. */ export function showGlobalMiniPlayer({ pathname }: { pathname: string }): boolean { return ( !pathname.startsWith("/player/") && !pathname.startsWith("/login") && !pathname.startsWith("/settings") ); } /** * Routes that render their own full-height flex column (header + scroller + * their own in-flow BottomUi). The root leaves these as a plain clipped box and * does not render its own BottomUi. Every other route renders into the root's * scroller, with the root's in-flow BottomUi as a flex sibling below it. */ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean { return ( pathname.startsWith("/library") || pathname.startsWith("/player/") || pathname.startsWith("/login") ); } /** * Whether the root layout should render the shared app header (logo, desktop * nav, and the account menu) for this route. * * Routes that own their layout (library) render their own AppHeader, so the * root must not double it up. `/settings` owns its content but deliberately has * no account menu (the user is already there). `/player/*` and `/login` are * immersive/chrome-free. Everything else authenticated (`/`, `/search`, * `/downloads`) gets the header from the root — the whole point of UR-054. * * TRACES: UR-054 | DR-076 */ export function showGlobalHeader({ pathname, isAuthenticated, }: BottomUiVisibilityInput): boolean { return ( isAuthenticated && !routeOwnsLayout({ pathname }) && !pathname.startsWith("/settings") ); } /** * Whether the header renders its search box on this route (md+ only; below md * the bottom-nav Search tab and /search's own input serve that role). * * `/search` is included deliberately: the bar is the single md+ search input, * so it must survive the hop onto the results page instead of being replaced by * a second input belonging to that page. The library routes keep it because * that is where a search is most often started. * * TRACES: UR-049, UR-054 | DR-063 */ export function showHeaderSearch({ pathname }: { pathname: string }): boolean { return pathname.startsWith("/library") || isSearchRoute(pathname); } /** * Whether any bottom UI is showing for this route (mini player, nav, or both). * The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte), * so this is purely a visibility question — there is no padding to reserve. */ export function showBottomUi(input: BottomUiVisibilityInput): boolean { return showBottomNav(input) || showGlobalMiniPlayer({ pathname: input.pathname }); } /** * Whether the app shell itself must reserve the bottom safe-area inset * (`--safe-bottom`, i.e. the Android navigation/gesture bar). * * Exactly one element may reserve it. BottomUi owns it whenever it renders, * because the padding belongs *inside* its surface box so the colour extends * behind the bar rather than leaving a strip of page background. On routes with * no bottom UI at all (login, the full-screen player) nothing else would, so * the shell takes it. * * TRACES: UR-066 | DR-112 */ export function shellReservesBottomInset(input: BottomUiVisibilityInput): boolean { return !showBottomUi(input); }