diff --git a/src/lib/components/AppHeader.svelte b/src/lib/components/AppHeader.svelte
new file mode 100644
index 00000000..3e907fb3
--- /dev/null
+++ b/src/lib/components/AppHeader.svelte
@@ -0,0 +1,77 @@
+
+
+
+
diff --git a/src/lib/components/account/AccountMenu.svelte b/src/lib/components/account/AccountMenu.svelte
new file mode 100644
index 00000000..c1fa616f
--- /dev/null
+++ b/src/lib/components/account/AccountMenu.svelte
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+ {#if open}
+
+
close()}
+ onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") close(); }}
+ role="button"
+ tabindex="-1"
+ aria-label="Close account menu"
+ >
+
+
+ {/if}
+
diff --git a/src/lib/components/account/AccountMenu.test.ts b/src/lib/components/account/AccountMenu.test.ts
new file mode 100644
index 00000000..9c403d7f
--- /dev/null
+++ b/src/lib/components/account/AccountMenu.test.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/svelte";
+
+// Controllable store shims + spies, declared via vi.hoisted so they exist when
+// the hoisted vi.mock factories run. A tiny writable shim avoids importing
+// svelte inside the hoisted block.
+const h = vi.hoisted(() => {
+ function shim(initial: T) {
+ let value = initial;
+ const subs = new Set<(v: T) => void>();
+ return {
+ set(v: T) {
+ value = v;
+ subs.forEach((fn) => fn(value));
+ },
+ subscribe(fn: (v: T) => void) {
+ subs.add(fn);
+ fn(value);
+ return () => subs.delete(fn);
+ },
+ };
+ }
+ return {
+ currentUserStore: shim<{ name: string } | null>({ name: "Ada" }),
+ serverNameStore: shim("Home Server"),
+ serverUrlStore: shim("https://media.example.com"),
+ logout: vi.fn(async () => {}),
+ reset: vi.fn(),
+ goto: vi.fn(),
+ };
+});
+
+vi.mock("$lib/stores/auth", () => ({
+ auth: { logout: h.logout },
+ currentUser: { subscribe: h.currentUserStore.subscribe },
+ serverName: { subscribe: h.serverNameStore.subscribe },
+ serverUrl: { subscribe: h.serverUrlStore.subscribe },
+}));
+
+vi.mock("$lib/stores/library", () => ({
+ library: { reset: h.reset },
+}));
+
+vi.mock("$app/navigation", () => ({ goto: h.goto }));
+
+import AccountMenu from "./AccountMenu.svelte";
+
+function openMenu() {
+ const trigger = screen.getByRole("button", { name: "Account menu" });
+ fireEvent.click(trigger);
+ return trigger;
+}
+
+describe("AccountMenu", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ h.currentUserStore.set({ name: "Ada" });
+ h.serverNameStore.set("Home Server");
+ h.serverUrlStore.set("https://media.example.com");
+ });
+
+ it("trigger toggles aria-expanded", async () => {
+ render(AccountMenu);
+ const trigger = screen.getByRole("button", { name: "Account menu" });
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ await fireEvent.click(trigger);
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ await fireEvent.click(trigger);
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("renders the documented items in order", async () => {
+ render(AccountMenu);
+ openMenu();
+ const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
+ expect(items).toEqual(["Downloads", "Settings", "Display", "Sign out"]);
+ });
+
+ it("shows the identity block with name and server host", async () => {
+ render(AccountMenu);
+ openMenu();
+ expect(screen.getByText("Signed in as")).toBeTruthy();
+ // "Ada" appears in both the trigger label and the identity block.
+ expect(screen.getAllByText("Ada").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText("Home Server")).toBeTruthy();
+ });
+
+ it("falls back to the URL host when no server name is set", async () => {
+ h.serverNameStore.set(null);
+ render(AccountMenu);
+ openMenu();
+ expect(screen.getByText("media.example.com")).toBeTruthy();
+ });
+
+ it("Escape closes the menu", async () => {
+ render(AccountMenu);
+ const trigger = openMenu();
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
+ await fireEvent.keyDown(window, { key: "Escape" });
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("backdrop click closes the menu", async () => {
+ render(AccountMenu);
+ const trigger = openMenu();
+ const backdrop = screen.getByRole("button", { name: "Close account menu" });
+ await fireEvent.click(backdrop);
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("Sign out logs out, resets library state, and redirects home", async () => {
+ render(AccountMenu);
+ openMenu();
+ const signOut = screen.getByRole("menuitem", { name: "Sign out" });
+ await fireEvent.click(signOut);
+ expect(h.logout).toHaveBeenCalledOnce();
+ expect(h.reset).toHaveBeenCalledOnce();
+ expect(h.goto).toHaveBeenCalledWith("/");
+ });
+
+ it("Sign out is the last item, after the routine navigation", async () => {
+ render(AccountMenu);
+ openMenu();
+ const items = screen.getAllByRole("menuitem").map((el) => el.textContent?.trim());
+ expect(items[items.length - 1]).toBe("Sign out");
+ });
+});
diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts
index fed840b8..77813262 100644
--- a/src/lib/stores/auth.ts
+++ b/src/lib/stores/auth.ts
@@ -569,3 +569,5 @@ export const securityWarning = derived(auth, ($auth) => $auth.securityWarning);
export const authError = derived(auth, ($auth) => $auth.error);
export const isVerifying = derived(auth, ($auth) => $auth.isVerifying);
export const sessionVerified = derived(auth, ($auth) => $auth.sessionVerified);
+export const serverName = derived(auth, ($auth) => $auth.serverName);
+export const serverUrl = derived(auth, ($auth) => $auth.serverUrl);
diff --git a/src/lib/stores/viewMode.test.ts b/src/lib/stores/viewMode.test.ts
new file mode 100644
index 00000000..cf3b4f5e
--- /dev/null
+++ b/src/lib/stores/viewMode.test.ts
@@ -0,0 +1,69 @@
+/**
+ * Display preference (grid/list) is a second view onto the library `viewMode`
+ * store. The Settings Display section and the library page-header toggle both
+ * drive it via `library.setViewMode`, so verifying the store writes through and
+ * persists covers the shared data path for both controls.
+ *
+ * TRACES: UR-029 | DR-077 | UT-*
+ */
+
+import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
+import { get } from "svelte/store";
+
+const STORAGE_KEY = "jellytau-view-mode";
+
+// jsdom here doesn't expose localStorage; stand in a minimal implementation.
+const backing = new Map();
+const localStorageShim = {
+ getItem: (key: string) => backing.get(key) ?? null,
+ setItem: (key: string, value: string) => void backing.set(key, value),
+ removeItem: (key: string) => void backing.delete(key),
+ clear: () => backing.clear(),
+};
+
+// The library store imports the auth store and tauri events at module load;
+// neither is exercised by these tests.
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: vi.fn(async () => () => {}),
+}));
+vi.mock("./auth", () => ({ auth: { getUserId: vi.fn() } }));
+
+beforeAll(() => {
+ vi.stubGlobal("localStorage", localStorageShim);
+});
+
+afterAll(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("viewMode display preference", () => {
+ beforeEach(() => {
+ backing.clear();
+ vi.resetModules();
+ });
+
+ it("setViewMode writes through to the derived store", async () => {
+ const { library, viewMode } = await import("./library");
+ library.setViewMode("list");
+ expect(get(viewMode)).toBe("list");
+ library.setViewMode("grid");
+ expect(get(viewMode)).toBe("grid");
+ });
+
+ it("setViewMode persists the choice to localStorage", async () => {
+ const { library } = await import("./library");
+ library.setViewMode("list");
+ expect(backing.get(STORAGE_KEY)).toBe("list");
+ });
+
+ it("a persisted list preference is restored on load", async () => {
+ backing.set(STORAGE_KEY, "list");
+ const { viewMode } = await import("./library");
+ expect(get(viewMode)).toBe("list");
+ });
+
+ it("defaults to grid when nothing is persisted", async () => {
+ const { viewMode } = await import("./library");
+ expect(get(viewMode)).toBe("grid");
+ });
+});
diff --git a/src/lib/utils/layoutShell.test.ts b/src/lib/utils/layoutShell.test.ts
index 89453ea1..c17a118d 100644
--- a/src/lib/utils/layoutShell.test.ts
+++ b/src/lib/utils/layoutShell.test.ts
@@ -18,6 +18,7 @@ import { describe, it, expect } from "vitest";
import {
showBottomNav,
showGlobalMiniPlayer,
+ showGlobalHeader,
routeOwnsLayout,
showBottomUi,
} from "./layoutShell";
@@ -87,6 +88,33 @@ describe("routeOwnsLayout", () => {
});
});
+describe("showGlobalHeader", () => {
+ it("shows the root-owned header (with account menu) on home, search, downloads", () => {
+ // The whole point of UR-054: account actions reachable without going to Library.
+ expect(showGlobalHeader(authed("/"))).toBe(true);
+ expect(showGlobalHeader(authed("/search"))).toBe(true);
+ expect(showGlobalHeader(authed("/downloads"))).toBe(true);
+ });
+
+ it("does NOT show on library (it renders its own AppHeader)", () => {
+ expect(showGlobalHeader(authed("/library"))).toBe(false);
+ expect(showGlobalHeader(authed("/library/abc"))).toBe(false);
+ });
+
+ it("does NOT show on settings (owns its content; no account menu needed there)", () => {
+ expect(showGlobalHeader(authed("/settings"))).toBe(false);
+ });
+
+ it("does NOT show on the immersive player or login", () => {
+ expect(showGlobalHeader(authed("/player/x"))).toBe(false);
+ expect(showGlobalHeader(authed("/login"))).toBe(false);
+ });
+
+ it("hides when unauthenticated", () => {
+ expect(showGlobalHeader({ pathname: "/", isAuthenticated: false })).toBe(false);
+ });
+});
+
describe("structural invariant: every route that shows bottom UI has a scroller above it", () => {
// With the in-flow model, "the bottom UI is a flex sibling below a scroller"
// must hold on every route where it shows. That scroller is provided by
diff --git a/src/lib/utils/layoutShell.ts b/src/lib/utils/layoutShell.ts
index e98aa00e..f10c7650 100644
--- a/src/lib/utils/layoutShell.ts
+++ b/src/lib/utils/layoutShell.ts
@@ -67,6 +67,29 @@ export function routeOwnsLayout({ pathname }: { pathname: string }): boolean {
);
}
+/**
+ * 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 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),
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 80fd5210..091b69f3 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -16,16 +16,22 @@
import Toast from "$lib/components/Toast.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
+ import AppHeader from "$lib/components/AppHeader.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import {
showBottomNav as computeShowBottomNav,
showGlobalMiniPlayer as computeShowGlobalMiniPlayer,
+ showGlobalHeader as computeShowGlobalHeader,
routeOwnsLayout as computeRouteOwnsLayout,
} from "$lib/utils/layoutShell";
import { registerNavigationTracking } from "$lib/utils/navigation";
+ import { startNetworkReporting } from "$lib/services/networkType";
let { children } = $props();
+ /** Teardown for the network-transport reporter (WiFi-only gate). */
+ let stopNetworkReporting: (() => void) | null = null;
+
// Track in-app navigation depth so the header "back" affordance knows when a
// real in-app Back exists (vs. a stale WebView stack after a background /
// restore). Must run during component init — afterNavigate needs a component
@@ -47,6 +53,13 @@
);
const showGlobalMiniPlayer = $derived(computeShowGlobalMiniPlayer({ pathname }));
+ // The shared header (with the account menu) is rendered by the root on every
+ // authenticated non-immersive route that doesn't own its own layout. Library
+ // renders its own AppHeader; settings/player/login get none. (UR-054)
+ const showGlobalHeader = $derived(
+ computeShowGlobalHeader({ pathname, isAuthenticated: $isAuthenticated })
+ );
+
// Library/settings/player/login own their own full-height flex column
// (header + scroller + their own in-flow BottomUi), so the root just clips
// and lets them manage layout. Every other route renders into the root's
@@ -76,6 +89,11 @@
// Initialize download event listener
await initDownloadEvents();
+ // Report the network transport to the backend and keep it current, so the
+ // WiFi-only download gate has real data to act on (UR-053). No-op on
+ // desktop, where the backend defaults to unmetered.
+ stopNetworkReporting = startNetworkReporting();
+
// Prime the downloads store from the DB. The store starts empty each launch,
// and download badges (e.g. AlbumDownloadButton) derive purely from it, so
// without an initial refresh a previously-downloaded album shows as
@@ -102,6 +120,7 @@
});
onDestroy(() => {
+ stopNetworkReporting?.();
cleanupPlayerEvents();
cleanupDownloadEvents();
connectivity.stopMonitoring();
@@ -189,6 +208,11 @@
{@render children()}
{:else}
+
+ {#if showGlobalHeader}
+
+ {/if}
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte
index 21af03ed..ab51046d 100644
--- a/src/routes/settings/+page.svelte
+++ b/src/routes/settings/+page.svelte
@@ -1,8 +1,13 @@
-
+