perf(web): smooth item sidebar collapse (#504)

This commit is contained in:
Suspense
2026-07-28 09:50:30 -04:00
committed by GitHub
parent 315f843fa5
commit 5d6f6323d5
28 changed files with 2389 additions and 676 deletions
+131 -8
View File
@@ -94,9 +94,13 @@
--duration-glacial: 700ms;
--duration-cinematic: 1200ms;
--duration-drift: 2000ms;
/* Sidebar immersion collapse — see `.sidebar-surface`. */
--duration-sidebar-collapse: 300ms;
/* ── Motion: Easing ────────────────────────────────────────── */
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
/* Long, flat tail — keeps the moving edge from appearing to step near the end. */
--ease-sidebar-collapse: cubic-bezier(0.32, 0.72, 0, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
@@ -260,14 +264,21 @@
--duration-drift: 0ms;
--duration-glacial: 0ms;
--duration-slow: 150ms;
--duration-sidebar-collapse: 0ms;
--animate-ken-burns-a: none;
--animate-ken-burns-b: none;
}
.ambient-glow {
transition: none;
}
.sidebar-transition,
.main-transition {
/* Collapsing the sidebar becomes an instant state change: the frame, the
content under it, and everything cross-fading inside all stop animating. */
.sidebar-surface,
.sidebar-inner,
.sidebar-fade,
.sidebar-row-shift,
.sidebar-main-stage,
.impersonation-banner {
transition: none;
}
.player-breathe,
@@ -857,13 +868,81 @@
opacity var(--duration-cinematic) var(--ease-gentle);
}
/* ── Sidebar Immersion Transitions ─────────────────────────── */
.sidebar-transition {
transition: width var(--duration-normal) var(--ease-default);
/* ── Sidebar Immersion ─────────────────────────────────────── */
/* The desktop sidebar is physically 260px wide in EVERY state. Entering a
detail page snaps the real layout — main's margin, the impersonation
banner, `--app-sidebar-offset` — from 260px to 64px in one pass, and the
sidebar and main content are held at their old visual positions for one
frame, then travel together to the 64px rail. The item route commits its
lightweight shell first; metadata and artwork stay gated until this
compositor motion has completed.
Nothing here touches width, margin, or any other layout property, so the
document is laid out once per navigation instead of once per frame. Holding
the surface at a constant size also means its 40px backdrop blur is
rasterized against unchanging geometry rather than re-blurred each frame. */
/* Two transforms that cancel out. `.sidebar-surface` is a 260px frame with
`overflow: hidden` that slides 196px left, so its right edge — and the
border drawn on it — sweeps from x=260 down to x=64. `.sidebar-inner`
slides the same distance the other way, so its content stays pinned to the
viewport and is progressively cut off by the frame's edge.
Why not animate the clip directly: Chrome runs `clip-path` transitions on
the main thread. Entering a detail page blocks that thread for longer than
the animation lasts (267ms and 209ms frozen frames measured on a real
navigation), which swallowed the collapse whole. `transform` is composited
and keeps advancing through exactly the same block. */
.sidebar-surface {
overflow: hidden;
transform: translateX(0);
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
.main-transition {
transition: margin-left var(--duration-normal) var(--ease-default);
.sidebar-surface[data-collapsed="true"] {
transform: translateX(-196px);
}
.sidebar-inner {
transform: translateX(0);
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
.sidebar-surface[data-collapsed="true"] .sidebar-inner {
transform: translateX(196px);
}
/* Main's margin snaps directly to the destination so the document lays out
once. Until the route is ready to animate, an inverse transform places the
new layout at the old visual offset. Removing that compensation on the
same state change that moves the sidebar keeps their edges locked together. */
@media (min-width: 64rem) {
.sidebar-main-stage {
transform: none;
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
.sidebar-main-stage[data-sidebar-target-collapsed="true"]:not(
[data-sidebar-visual-collapsed="true"]
) {
transform: translateX(196px);
transition: none;
}
.sidebar-main-stage:not(
[data-sidebar-target-collapsed="true"]
)[data-sidebar-visual-collapsed="true"] {
transform: translateX(-196px);
transition: none;
}
}
/* Sidebar contents keep their expanded layout in both states — only opacity
and transform change, so the nav never reflows mid-animation. Labels that
used to animate `max-width` now just fade; the frame does the hiding. */
.sidebar-fade {
transition: opacity var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
.sidebar-row-shift {
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
.sidebar-surface[data-collapsed="true"] .sidebar-row-shift {
/* Slides the library rows left by their reserved chevron slot so their
icons line up with the rest of the rail's icon column. */
transform: translateX(var(--sidebar-row-shift, 0px));
}
/* ── Sidebar Scrollbar ────────────────────────────────────── */
@@ -1848,8 +1927,10 @@
--impersonation-accent-soft: oklch(0.82 0.14 78 / 0.12);
--impersonation-accent-line: oklch(0.82 0.14 78 / 0.32);
/* Snaps straight to the destination so the document lays out once; the
compensation transform below carries the edge across. Animating this
margin instead would re-run layout on every frame. */
margin-left: var(--app-sidebar-offset);
transition: margin-left var(--duration-normal) var(--ease-default);
position: sticky;
background: linear-gradient(
180deg,
@@ -1864,6 +1945,48 @@
0 10px 32px -20px rgb(0 0 0 / 0.65);
}
/* The banner renders above the routes, outside Layout, so it tracks the
sidebar through root attributes rather than the props `.sidebar-main-stage`
reads in-tree. The mechanism is identical: the margin above snaps to the
destination, and an inverse transform holds the previous visual offset for
one frame before releasing to zero on the same state change that moves the
sidebar — so both edges travel together on the compositor.
Without this the banner jumped instantly while the sidebar took 300ms, and
since it is `z-50` against the sidebar's `z-40`/`45` it painted over the
logo on the way in and left a gap on the way out.
Breakpoint deliberately matches `--app-sidebar-offset` (1024px) rather than
Tailwind's `lg` (64rem): `html[data-text-scale]` re-bases rem, so at large
text those two diverge and the compensation must follow whichever query
actually changes the margin it is compensating for. */
/* While the banner holds its previous offset it is translated right, so its
right edge sits briefly past the viewport. Layout already clips its own
subtree this way; the banner is mounted above it, so the app root needs the
same treatment or that overhang becomes a document-level horizontal scroll
for the length of the transition. `clip` rather than `hidden` so this never
becomes a scroll container and the banner's `position: sticky` keeps
resolving against the viewport. */
#root {
overflow-x: clip;
}
@media (min-width: 1024px) {
.impersonation-banner {
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
}
:root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])
.impersonation-banner {
transform: translateX(196px);
transition: none;
}
:root:not([data-sidebar-collapsed="true"])[data-sidebar-visual-collapsed="true"]
.impersonation-banner {
transform: translateX(-196px);
transition: none;
}
}
/* Luminous top accent line — fades in from both edges so the
banner reads as a deliberate strip rather than a hard bar. */
.impersonation-banner::before {
+52
View File
@@ -1,3 +1,5 @@
import type { CSSProperties } from "react";
export function isSidebarExpanded(collapsed: boolean, hovered: boolean, profileMenuOpen: boolean) {
return !collapsed || hovered || profileMenuOpen;
}
@@ -6,6 +8,56 @@ export function getProfileMenuSide(collapsed: boolean) {
return collapsed ? "right" : "top";
}
/** Visible width of the collapsed rail, in px. */
export const SIDEBAR_RAIL_WIDTH = 64;
/** Physical width of the sidebar surface in every state, in px. */
export const SIDEBAR_SURFACE_WIDTH = 260;
/**
* True when the surface should be reduced to the rail.
*
* The `<aside>` is always {@link SIDEBAR_SURFACE_WIDTH} wide — nothing about it
* reflows between states. This drives `data-collapsed`, which app.css turns into
* paired transform animation, so the only things that move are compositor
* layers over a surface whose 40px backdrop blur has fixed geometry.
*/
export function isSidebarRailCollapsed(collapsed: boolean, sidebarExpanded: boolean) {
return collapsed && !sidebarExpanded;
}
/**
* How far a library row slides left once the surface is clipped to the rail, so
* its icon lines up with the rest of the icon column.
*
* The slot reserved ahead of the icon is a chevron button when the library has
* pins — 12px padding + a 14px glyph + 4px = 30px — and a 12px border-box
* spacer otherwise (`w-3` includes its own `pl-3` under Tailwind's preflight).
* Every other nav row starts its icon 12px into the row, so only the chevron
* case is out of line; the spacer already matches and must not be shifted.
*/
export function libraryRowShift(hasPins: boolean) {
return hasPins ? "-18px" : "0px";
}
/**
* Inline style for the sidebar surface.
*
* While hover-expanded on a detail page the sidebar floats over the page as an
* overlay (raised z-index + drop shadow) so expanding it never moves or resizes
* the main content, which stays pinned to the collapsed 64px offset.
*/
export function sidebarSurfaceStyle({
collapsed,
sidebarExpanded,
}: {
collapsed: boolean;
sidebarExpanded: boolean;
}): CSSProperties | undefined {
if (!collapsed || !sidebarExpanded) return undefined;
return { zIndex: 45, boxShadow: "0 25px 50px -12px rgb(0 0 0 / 0.5)" };
}
export interface AppNavLink {
id: string;
basePath: string;
+145 -9
View File
@@ -1,4 +1,4 @@
// @vitest-environment node
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -12,6 +12,9 @@ import {
getProfileMenuSide,
groupAppNavLinks,
isSidebarExpanded,
isSidebarRailCollapsed,
libraryRowShift,
sidebarSurfaceStyle,
type AppNavLink,
} from "./AppSidebar.logic";
@@ -148,6 +151,10 @@ function renderSidebar(entry: string, { collapsed = false }: { collapsed?: boole
);
}
function parseMarkup(markup: string): Document {
return new DOMParser().parseFromString(markup, "text/html");
}
describe("AppSidebar", () => {
beforeEach(() => {
mockLogout.mockReset();
@@ -171,12 +178,45 @@ describe("AppSidebar", () => {
expect(markup).not.toContain("▶");
});
it("uses the icon-only mark when the sidebar is collapsed", () => {
const markup = renderSidebar("/", { collapsed: true });
it("cross-fades the wordmark and the mark instead of swapping them", () => {
// Both are always rendered and only their opacity differs; swapping the
// variant outright popped at the start of the paired transform transition.
const collapsed = renderSidebar("/", { collapsed: true });
const expanded = renderSidebar("/");
expect(markup).toContain('src="/silo-icon-1024.png"');
expect(markup).not.toContain('src="/silo-wordmark-sidebar.png"');
expect(markup).toContain("sidebar-logo flex items-center py-6 justify-center px-2");
for (const markup of [collapsed, expanded]) {
expect(markup).toContain('src="/silo-icon-1024.png"');
expect(markup).toContain('src="/silo-wordmark-sidebar.png"');
}
// Collapsed: the mark is the one showing, and the wordmark is out of the
// accessibility tree so the two images never both name the sidebar.
const collapsedDocument = parseMarkup(collapsed);
const expandedDocument = parseMarkup(expanded);
const collapsedWordmark = collapsedDocument
.querySelector('img[src="/silo-wordmark-sidebar.png"]')
?.closest(".sidebar-fade");
const collapsedMark = collapsedDocument
.querySelector('img[src="/silo-icon-1024.png"]')
?.closest(".sidebar-fade");
const expandedWordmark = expandedDocument
.querySelector('img[src="/silo-wordmark-sidebar.png"]')
?.closest(".sidebar-fade");
const expandedMark = expandedDocument
.querySelector('img[src="/silo-icon-1024.png"]')
?.closest(".sidebar-fade");
expect(collapsedWordmark?.getAttribute("aria-hidden")).toBe("true");
expect(collapsedWordmark?.classList.contains("left-5")).toBe(true);
expect(collapsedWordmark?.classList.contains("opacity-0")).toBe(true);
expect(collapsedMark?.getAttribute("aria-hidden")).toBe("false");
expect(collapsedMark?.classList.contains("left-3.5")).toBe(true);
expect(collapsedMark?.classList.contains("opacity-100")).toBe(true);
expect(expandedWordmark?.getAttribute("aria-hidden")).toBe("false");
expect(expandedWordmark?.classList.contains("left-5")).toBe(true);
expect(expandedWordmark?.classList.contains("opacity-100")).toBe(true);
expect(expandedMark?.getAttribute("aria-hidden")).toBe("true");
expect(expandedMark?.classList.contains("left-3.5")).toBe(true);
expect(expandedMark?.classList.contains("opacity-0")).toBe(true);
});
it("uses the cinema highlight text color for active pinned catalog destinations", () => {
@@ -214,10 +254,16 @@ describe("AppSidebar", () => {
expect(getProfileMenuSide(true)).toBe("right");
});
it("centers the profile trigger when the sidebar is collapsed", () => {
const markup = renderSidebar("/item/42", { collapsed: true });
it("keeps the profile trigger left-anchored and the same size in both states", () => {
// `mx-auto` would centre the avatar in the 260px surface — far outside the
// 64px rail — and any width swap would pop when the surface starts moving.
const collapsed = renderSidebar("/item/42", { collapsed: true });
const expanded = renderSidebar("/", { collapsed: false });
expect(markup).toContain("mx-auto h-10 w-10 justify-center px-0");
for (const markup of [collapsed, expanded]) {
expect(markup).toContain("flex w-full items-center gap-2.5 rounded-xl px-3 py-3");
expect(markup).not.toContain("mx-auto h-10 w-10 justify-center px-0");
}
});
it("keeps a flat Apps list when fewer than 2 distinct categories exist", () => {
@@ -277,6 +323,96 @@ describe("AppSidebar", () => {
});
});
describe("sidebar collapse surface", () => {
it("keeps the surface 260px wide on a detail route and everywhere else", () => {
// Nothing about the sidebar's box changes between states — the frame just
// slides, so there is never a width to interpolate.
for (const markup of [renderSidebar("/"), renderSidebar("/item/42", { collapsed: true })]) {
const aside = markup.match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
expect(aside).toContain("w-[260px]");
expect(aside).toContain("overflow-hidden");
expect(aside).not.toContain("w-16");
}
});
it("puts the blur on the counter-translated layer, not the sliding frame", () => {
// The inner layer is screen-static, so its 40px backdrop is sampled from a
// region that never moves; on the frame it would be re-blurred per frame.
const markup = renderSidebar("/item/42", { collapsed: true });
const aside = markup.match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
expect(aside).not.toContain("backdrop-blur");
expect(markup).toContain("bg-sidebar/88 sidebar-inner");
expect(markup).toContain("backdrop-blur-2xl");
});
it("keeps the rail border on the frame so it rides along to x=64", () => {
const aside =
renderSidebar("/item/42", { collapsed: true }).match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
expect(aside).toContain("border-sidebar-border/70");
expect(aside).toContain("border-r");
});
it("marks the surface collapsed only on a detail route", () => {
expect(renderSidebar("/item/42", { collapsed: true })).toContain('data-collapsed="true"');
expect(renderSidebar("/")).not.toContain("data-collapsed");
});
it("keeps labels at their full layout box so the nav never reflows", () => {
const collapsed = renderSidebar("/item/42", { collapsed: true });
expect(collapsed).toContain("sidebar-fade max-w-[180px] truncate opacity-0");
// The old max-width animation relaid out the whole nav subtree per frame.
expect(collapsed).not.toContain("max-w-0");
expect(collapsed).not.toContain("transition-[opacity,max-width]");
});
it("reserves the library chevron slot in both states and shifts the row instead", () => {
const collapsed = renderSidebar("/item/42", { collapsed: true });
const expanded = renderSidebar("/");
for (const markup of [collapsed, expanded]) {
expect(markup).toContain("sidebar-row-shift flex flex-1 items-center");
// Mocked library 7 has a pin, so its slot is the wider chevron button.
expect(markup).toContain("--sidebar-row-shift:-18px");
}
});
});
describe("libraryRowShift", () => {
it("only shifts rows whose chevron slot pushes the icon out of the column", () => {
// A pinned library reserves a 30px chevron button; every other nav row
// starts its icon 12px in, so the row has to come back 18px.
expect(libraryRowShift(true)).toBe("-18px");
// The unpinned spacer is `w-3` with `pl-3` — border-box, so 12px total.
// It already lines up, and shifting it would overshoot to the left.
expect(libraryRowShift(false)).toBe("0px");
});
});
describe("isSidebarRailCollapsed", () => {
it("clips to the rail only when the route collapsed it and nothing holds it open", () => {
expect(isSidebarRailCollapsed(true, false)).toBe(true);
expect(isSidebarRailCollapsed(false, true)).toBe(false);
// Hover-to-expand and the profile menu both re-open the clip.
expect(isSidebarRailCollapsed(true, true)).toBe(false);
});
});
describe("sidebarSurfaceStyle", () => {
it("floats hover expansion above the page instead of resizing it", () => {
const style = sidebarSurfaceStyle({ collapsed: true, sidebarExpanded: true });
expect(style?.zIndex).toBe(45);
expect(style?.boxShadow).toContain("0 25px 50px -12px");
});
it("does not raise or shadow the surface in either resting state", () => {
expect(sidebarSurfaceStyle({ collapsed: true, sidebarExpanded: false })).toBeUndefined();
expect(sidebarSurfaceStyle({ collapsed: false, sidebarExpanded: true })).toBeUndefined();
});
});
describe("groupAppNavLinks", () => {
const link = (id: string, category?: string): AppNavLink => ({
id,
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -6,6 +6,7 @@ import { VisuallyHidden } from "radix-ui";
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
import { useDebounce } from "@/hooks/useDebounce";
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
import { useSidebarItemNavigation } from "@/components/sidebarItemNavigationContext";
import { createEmptyQueryDefinition, type BrowseItem } from "@/api/types";
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
import { useSearchMediaScope } from "@/hooks/useSearchMediaScope";
@@ -111,6 +112,7 @@ export function GlobalSearch({
const [query, setQuery] = useState(initialQuery);
const [selectedIndex, setSelectedIndex] = useState(-1);
const navigate = useViewTransitionNavigate();
const beginSidebarItemNavigation = useSidebarItemNavigation();
const debouncedQuery = useDebounce(query.trim(), DEBOUNCE_MS);
const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS);
const canRequest = useCanRequest();
@@ -191,11 +193,12 @@ export function GlobalSearch({
const handlePickItem = useCallback(
(contentId: string) => {
navigate(`/item/${encodeURIComponent(contentId)}`);
const href = `/item/${encodeURIComponent(contentId)}`;
if (!beginSidebarItemNavigation?.({ href })) navigate(href);
setOpen(false);
setQuery("");
},
[navigate],
[beginSidebarItemNavigation, navigate],
);
// Reset selectedIndex when query changes
+3 -2
View File
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import type { SectionItem } from "@/api/types";
import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import ViewTransitionLink from "@/components/ViewTransitionLink";
interface HeroBannerProps {
items: SectionItem[];
@@ -264,13 +265,13 @@ export default function HeroBanner({
)}
{playLabel}
</Link>
<Link
<ViewTransitionLink
to={buildItemHref({ contentId: current.content_id, libraryId })}
className="pill pill-glass transition-colors duration-[--duration-fast]"
>
<Info className="h-4 w-4" />
More Info
</Link>
</ViewTransitionLink>
</div>
</div>
</div>
+328
View File
@@ -0,0 +1,328 @@
import type { ReactNode } from "react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { catalogKeys } from "@/hooks/queries/keys";
import { SIDEBAR_DETAILS_REVEAL_DEADLINE_MS } from "./sidebarItemNavigation";
const mocks = vi.hoisted(() => ({
location: { pathname: "/", search: "", key: "home" },
navigate: vi.fn(),
prefetchQuery: vi.fn(),
renderSurface: true,
beginResult: undefined as boolean | undefined,
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useLocation: () => mocks.location,
useNavigate: () => mocks.navigate,
};
});
vi.mock("@tanstack/react-query", async () => {
const actual =
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
return { ...actual, useQueryClient: () => ({ prefetchQuery: mocks.prefetchQuery }) };
});
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { username: "Admin" } }) }));
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => ({ profile: { name: "Admin" } }),
}));
vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => false }));
vi.mock("@/playback/watchPlaybackContext", () => ({
useWatchPlaybackController: () => ({ isBackgroundBarVisible: false }),
}));
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
useAudiobookPlaybackController: () => null,
}));
vi.mock("@/hooks/queries/catalogRead", () => ({ fetchCatalogItemDetail: vi.fn() }));
vi.mock("@/components/GlobalSearch", () => ({ GlobalSearch: () => null }));
vi.mock("@/components/ServerActivity", () => ({ default: () => null }));
vi.mock("@/components/SiloBrand", () => ({ SiloBrand: () => <span>Silo</span> }));
vi.mock("@/components/ViewTransitionLink", () => ({
default: ({ children }: { children: ReactNode }) => <a href="/">{children}</a>,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: () => null,
SheetContent: ({ children }: { children: ReactNode }) => <>{children}</>,
SheetHeader: ({ children }: { children: ReactNode }) => <>{children}</>,
SheetTitle: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/AppSidebar", () => ({
default: ({ collapsed }: { collapsed?: boolean }) =>
mocks.renderSurface ? (
<aside
className="sidebar-surface"
data-collapsed={collapsed ? "true" : undefined}
data-testid="sidebar-surface"
>
<div className="sidebar-inner" data-testid="sidebar-inner" />
<div className="sidebar-row-shift" data-testid="sidebar-row" />
</aside>
) : null,
}));
import Layout from "./Layout";
import {
useSidebarItemDetailsReady,
useSidebarItemNavigation,
} from "./sidebarItemNavigationContext";
function Harness() {
const begin = useSidebarItemNavigation();
const ready = useSidebarItemDetailsReady();
return (
<>
<output aria-label="details-ready">{String(ready)}</output>
<button
onClick={() => {
mocks.beginResult = begin?.({
href: "/item/movie-1?libraryId=2",
replace: true,
state: { source: "home" },
});
}}
>
begin item
</button>
<button
onClick={() => {
mocks.beginResult = begin?.({ href: "/library/1" });
}}
>
begin library
</button>
</>
);
}
function renderLayout() {
return render(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
}
function setRoute(pathname: string, key: string) {
mocks.location = { pathname, search: "", key };
}
beforeEach(() => {
vi.useFakeTimers();
mocks.location = { pathname: "/", search: "", key: "home" };
mocks.navigate.mockReset();
mocks.prefetchQuery.mockReset();
mocks.renderSurface = true;
mocks.beginResult = undefined;
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(min-width: 64rem)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 1),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("Layout item navigation", () => {
it("declines interception on an item route, for a non-item href, and below lg", () => {
const view = renderLayout();
const begin = screen.getByRole("button", { name: "begin item" });
setRoute("/item/already-there", "item");
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
fireEvent.click(begin);
expect(mocks.beginResult).toBe(false);
expect(mocks.navigate).not.toHaveBeenCalled();
setRoute("/", "home-2");
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "begin library" }));
expect(mocks.beginResult).toBe(false);
expect(mocks.navigate).not.toHaveBeenCalled();
const contextBegin = screen.getByRole("button", { name: "begin item" });
fireEvent.click(contextBegin);
expect(mocks.beginResult).toBe(true);
expect(mocks.navigate).toHaveBeenCalledOnce();
mocks.navigate.mockReset();
vi.stubGlobal("matchMedia", () => ({
matches: false,
media: "(min-width: 64rem)",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("button", { name: "begin item" }));
expect(mocks.beginResult).toBe(false);
expect(mocks.navigate).not.toHaveBeenCalled();
});
it("prefetches and forwards navigation options on a successful intercept", () => {
renderLayout();
fireEvent.click(screen.getByRole("button", { name: "begin item" }));
expect(mocks.prefetchQuery).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: catalogKeys.itemDetail("movie-1", 2) }),
);
expect(mocks.navigate).toHaveBeenCalledWith("/item/movie-1?libraryId=2", {
replace: true,
state: { source: "home" },
viewTransition: true,
});
});
});
describe("Layout detail reveal", () => {
it("reveals immediately when the sidebar surface is absent", () => {
mocks.renderSurface = false;
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
});
it("reveals as soon as the surface settles", () => {
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false");
screen.getByTestId("sidebar-surface").setAttribute("data-collapsed", "true");
act(() => vi.advanceTimersByTime(50));
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
});
it("reveals by the deadline while hover expansion holds the surface open", () => {
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS));
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
});
it("reveals by the deadline when requestAnimationFrame never fires", () => {
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS));
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
});
it("cancels reveal polling on unmount", () => {
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
expect(vi.getTimerCount()).toBeGreaterThan(0);
view.unmount();
expect(vi.getTimerCount()).toBe(0);
});
it("reveals only for a collapsed surface transform transition", () => {
const view = renderLayout();
setRoute("/item/movie-1", "item");
act(() =>
view.rerender(
<MemoryRouter>
<Layout>
<Harness />
</Layout>
</MemoryRouter>,
),
);
const surface = screen.getByTestId("sidebar-surface");
surface.setAttribute("data-collapsed", "true");
fireEvent.transitionEnd(screen.getByTestId("sidebar-inner"), { propertyName: "transform" });
fireEvent.transitionEnd(screen.getByTestId("sidebar-row"), { propertyName: "transform" });
fireEvent.transitionEnd(surface, { propertyName: "opacity" });
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false");
fireEvent.transitionEnd(surface, { propertyName: "transform" });
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
});
});
+199 -88
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { Menu, Search } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAuth } from "@/hooks/useAuth";
@@ -11,9 +12,24 @@ import { SiloBrand } from "@/components/SiloBrand";
import { GlobalSearch } from "@/components/GlobalSearch";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalogSearchParams";
import type { ReactNode } from "react";
import type { ReactNode, TransitionEvent as ReactTransitionEvent } from "react";
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
import {
prefersReducedMotion,
useImmediateSidebarCollapse,
} from "@/hooks/useImmediateSidebarCollapse";
import SidebarItemNavigationProvider from "@/components/SidebarItemNavigationProvider";
import {
hasRunningSidebarTransition,
isCollapsedSidebarSurface,
parseItemNavigationHref,
SIDEBAR_DETAILS_REVEAL_DEADLINE_MS,
type SidebarItemNavigationRequest,
} from "@/components/sidebarItemNavigation";
import { useSidebarItemDetailsGate } from "@/hooks/useSidebarItemDetailsGate";
import { catalogKeys } from "@/hooks/queries/keys";
import { fetchCatalogItemDetail } from "@/hooks/queries/catalogRead";
interface LayoutProps {
children: ReactNode;
@@ -21,6 +37,8 @@ interface LayoutProps {
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [mobileOpen, setMobileOpen] = useState(false);
// Tracks whether the mobile header should slide off-screen on scroll.
// We auto-hide on Calendar to maximize vertical room for the sticky week strip
@@ -36,6 +54,9 @@ export default function Layout({ children }: LayoutProps) {
const isHomePath = location.pathname === "/";
const isLibraryRoute = location.pathname.startsWith("/library/");
const isItemRoute = location.pathname.startsWith("/item/");
// Breakpoint changes naturally cause other layout renders; navigation only
// needs the viewport value at the moment it is attempted.
const hasDesktopSidebar = window.matchMedia("(min-width: 64rem)").matches;
const isSearchLandingRoute =
location.pathname === "/catalog" &&
(() => {
@@ -54,23 +75,107 @@ export default function Layout({ children }: LayoutProps) {
isRecommendationsRoute ||
isCalendarRoute;
// Immersion: auto-collapse sidebar on detail pages so content gets full stage
// The item route commits its lightweight shell immediately. The following
// frame starts the sidebar/main compositor transition; prefetched details
// and artwork are revealed only after that motion completes.
const isDetailImmersion = isItemRoute;
const targetDetailImmersion = isDetailImmersion;
const visualDetailImmersion = useImmediateSidebarCollapse(targetDetailImmersion);
const {
itemDetailsReady,
pendingLocationKey,
reveal: revealItemDetails,
} = useSidebarItemDetailsGate(location.key, isItemRoute && hasDesktopSidebar);
// Publish the current sidebar width on the document root so out-of-tree
// chrome (notably ImpersonationBanner, which renders above all routes)
// can align with whichever sidebar state is active.
const beginItemNavigation = useCallback(
(request: SidebarItemNavigationRequest) => {
const itemTarget = parseItemNavigationHref(request.href, window.location.origin);
if (isItemRoute || !itemTarget || !hasDesktopSidebar) {
return false;
}
void queryClient.prefetchQuery({
queryKey: catalogKeys.itemDetail(itemTarget.contentId, itemTarget.libraryId),
queryFn: () => fetchCatalogItemDetail(itemTarget.contentId, itemTarget.libraryId),
});
navigate(request.href, {
replace: request.replace,
state: request.state,
viewTransition: true,
});
return true;
},
[hasDesktopSidebar, isItemRoute, navigate, queryClient],
);
// Transitionend is the fast path. Polling handles reconstructed layers, and
// the hard deadline guarantees hover expansion or a hidden-tab suspended rAF
// can never leave the item route on its lightweight shell indefinitely.
useEffect(() => {
if (!isItemRoute || !pendingLocationKey) return;
const startedAt = Date.now();
let timer: number;
let cancelled = false;
const revealWhenSettled = () => {
if (cancelled) return;
const surface = document.querySelector<HTMLElement>(".sidebar-surface");
const deadlineReached =
prefersReducedMotion() || Date.now() - startedAt >= SIDEBAR_DETAILS_REVEAL_DEADLINE_MS;
if (
!deadlineReached &&
surface &&
(!isCollapsedSidebarSurface(surface) || hasRunningSidebarTransition(surface))
) {
const remaining = SIDEBAR_DETAILS_REVEAL_DEADLINE_MS - (Date.now() - startedAt);
timer = window.setTimeout(revealWhenSettled, Math.min(50, Math.max(0, remaining)));
return;
}
revealItemDetails(pendingLocationKey);
};
revealWhenSettled();
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isItemRoute, pendingLocationKey, revealItemDetails]);
const handleSidebarTransitionEnd = useCallback(
(event: ReactTransitionEvent<HTMLDivElement>) => {
if (event.propertyName === "transform" && isCollapsedSidebarSurface(event.target)) {
if (pendingLocationKey) revealItemDetails(pendingLocationKey);
}
},
[pendingLocationKey, revealItemDetails],
);
// Publish both sidebar states on the document root so out-of-tree chrome
// (notably ImpersonationBanner, which renders above all routes) can align
// with the sidebar. The target drives the snapped `--app-sidebar-offset`;
// the visual state is the same one-frame handoff `.sidebar-main-stage` uses
// in-tree, letting that chrome animate its edge instead of jumping.
//
// Layout effect, not effect: the sidebar receives `data-collapsed` during
// render, so publishing these after paint would start the banner's transition
// a frame late and it would trail the sidebar by ~23px at peak velocity.
useLayoutEffect(() => {
const root = document.documentElement;
if (isDetailImmersion) {
if (targetDetailImmersion) {
root.dataset.sidebarCollapsed = "true";
} else {
delete root.dataset.sidebarCollapsed;
}
if (visualDetailImmersion) {
root.dataset.sidebarVisualCollapsed = "true";
} else {
delete root.dataset.sidebarVisualCollapsed;
}
return () => {
delete root.dataset.sidebarCollapsed;
delete root.dataset.sidebarVisualCollapsed;
};
}, [isDetailImmersion]);
}, [targetDetailImmersion, visualDetailImmersion]);
// Auto-hide the mobile header on scroll-down for the Calendar route only.
// Direction-based (not threshold-based) so a small scroll-up reveals the
@@ -116,91 +221,97 @@ export default function Layout({ children }: LayoutProps) {
}, [mobileHeaderHidden]);
return (
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
<a
href="#main-content"
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
>
Skip to content
</a>
<GlobalSearch />
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
{/* Desktop sidebar — hidden below lg */}
<div className="hidden lg:block">
<AppSidebar collapsed={isDetailImmersion} />
</div>
<SidebarItemNavigationProvider begin={beginItemNavigation} itemDetailsReady={itemDetailsReady}>
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
<a
href="#main-content"
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
>
Skip to content
</a>
<GlobalSearch />
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
{/* Desktop sidebar — hidden below lg. Its box stays 260px wide in both
states; `collapsed` only drives the moving frame that hides everything
past the 64px rail, so entering a detail page never resizes it. */}
<div className="hidden lg:block" onTransitionEnd={handleSidebarTransitionEnd}>
<AppSidebar collapsed={visualDetailImmersion} />
</div>
{/* Mobile header — visible below lg. Slides up on scroll-down within
{/* Mobile header — visible below lg. Slides up on scroll-down within
the Calendar route to free vertical space; pulling up reveals it. */}
<div
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
}`}
>
<div className="flex items-center gap-3">
<button
onClick={() => setMobileOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
<SiloBrand className="h-10 w-[94px]" />
</ViewTransitionLink>
<div
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
}`}
>
<div className="flex items-center gap-3">
<button
onClick={() => setMobileOpen(true)}
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
<SiloBrand className="h-10 w-[94px]" />
</ViewTransitionLink>
</div>
<div className="flex items-center gap-2">
<ViewTransitionLink
to={buildQueryCatalogHref()}
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
>
<Search className="h-5 w-5" />
</ViewTransitionLink>
{showAdminActivity && <ServerActivity hideWhenEmpty />}
<Link
to="/settings/playback"
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
>
{profile?.name?.charAt(0).toUpperCase() ??
user?.username?.charAt(0).toUpperCase() ??
"?"}
</Link>
</div>
</div>
<div className="flex items-center gap-2">
<ViewTransitionLink
to={buildQueryCatalogHref()}
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
>
<Search className="h-5 w-5" />
</ViewTransitionLink>
{showAdminActivity && <ServerActivity hideWhenEmpty />}
<Link
to="/settings/playback"
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
>
{profile?.name?.charAt(0).toUpperCase() ??
user?.username?.charAt(0).toUpperCase() ??
"?"}
</Link>
</div>
</div>
{/* Mobile sidebar drawer */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<AppSidebar onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
{/* Mobile sidebar drawer */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<AppSidebar onNavigate={() => setMobileOpen(false)} />
</SheetContent>
</Sheet>
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
{showAdminActivity && (
<div className="fixed top-6 right-5 z-40 hidden lg:block">
<ServerActivity hideWhenEmpty />
</div>
)}
{/* Main content — offset by sidebar width on desktop */}
<main
id="main-content"
className={`main-transition relative min-h-screen ${
isDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
} ${hasBackgroundBar ? "pb-32 sm:pb-36" : ""}`}
style={{ viewTransitionName: "main-content" }}
>
{needsNoPadding ? (
children
) : (
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
{children}
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
{showAdminActivity && (
<div className="fixed top-6 right-5 z-40 hidden lg:block">
<ServerActivity hideWhenEmpty />
</div>
)}
</main>
</div>
{/* Main content — offset by sidebar width on desktop */}
<main
id="main-content"
data-sidebar-target-collapsed={targetDetailImmersion ? "true" : undefined}
data-sidebar-visual-collapsed={visualDetailImmersion ? "true" : undefined}
className={`sidebar-main-stage relative min-h-screen ${
targetDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
} ${hasBackgroundBar ? "pb-32 sm:pb-36" : ""}`}
style={{ viewTransitionName: "main-content" }}
>
{needsNoPadding ? (
children
) : (
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
{children}
</div>
)}
</main>
</div>
</SidebarItemNavigationProvider>
);
}
@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import type { ResolvedSection } from "@/api/types";
import SidebarItemNavigationProvider from "./SidebarItemNavigationProvider";
import NowListeningHero from "./NowListeningHero";
vi.mock("@/hooks/queries/catalogRead", () => ({
useCatalogItemDetail: () => ({ data: undefined }),
}));
vi.mock("@/hooks/useAmbientColor", () => ({ useAmbientColor: () => undefined }));
vi.mock("@/hooks/useOverlayPrefs", () => ({ useOverlayPrefs: () => ({ prefs: {} }) }));
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
useAudiobookPlaybackController: () => null,
}));
describe("NowListeningHero", () => {
it("routes item details through the sidebar interception path", () => {
const begin = vi.fn(() => true);
const section = {
title: "Continue listening",
items: [
{
content_id: "audiobook-1",
type: "audiobook",
title: "The Book",
position_seconds: 120,
duration_seconds: 3600,
},
],
} as unknown as ResolvedSection;
render(
<MemoryRouter>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<NowListeningHero section={section} libraryId={7} />
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("link", { name: "More Info" }));
expect(begin).toHaveBeenCalledWith({
href: "/item/audiobook-1?libraryId=7",
replace: undefined,
state: undefined,
});
});
});
+5 -4
View File
@@ -3,6 +3,7 @@ import { Link } from "react-router";
import { Info, Pause, Play } from "lucide-react";
import type { ResolvedSection } from "@/api/types";
import ContinueWatchingCard from "@/components/ContinueWatchingCard";
import ViewTransitionLink from "@/components/ViewTransitionLink";
import MediaCarousel from "@/components/MediaCarousel";
import { useCatalogItemDetail } from "@/hooks/queries/catalogRead";
import { useAmbientColor } from "@/hooks/useAmbientColor";
@@ -150,7 +151,7 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
<div className="relative z-10 px-4 pt-28 pb-10 sm:px-6 sm:pt-32 sm:pb-12 lg:px-10 xl:px-12">
<div className="flex max-w-[1380px] flex-col gap-6 sm:flex-row sm:items-center sm:gap-10">
<Link
<ViewTransitionLink
to={itemHref}
className="block w-36 shrink-0 self-start sm:w-48 sm:self-center lg:w-56"
>
@@ -170,7 +171,7 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
/>
)}
</div>
</Link>
</ViewTransitionLink>
<div className="min-w-0 flex-1">
<p className="hero-eyebrow mb-3">
@@ -216,13 +217,13 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
)}
{resumeLabel}
</Link>
<Link
<ViewTransitionLink
to={itemHref}
className="pill pill-glass transition-colors duration-[--duration-fast]"
>
<Info className="h-4 w-4" />
More Info
</Link>
</ViewTransitionLink>
</div>
</div>
</div>
+3 -2
View File
@@ -3,6 +3,7 @@ import { Check, Film, Library, Loader2, Plus, Tv } from "lucide-react";
import type { MediaRequest, RequestMediaResult } from "@/api/types";
import { cn } from "@/lib/utils";
import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
import ViewTransitionLink from "@/components/ViewTransitionLink";
const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
@@ -198,14 +199,14 @@ function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }
function LibraryCardLink({ contentID, title }: { contentID: string; title: string }) {
return (
<Link
<ViewTransitionLink
to={`/item/${encodeURIComponent(contentID)}`}
aria-label={`Open ${title} in library`}
className="absolute top-2 left-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-full bg-black/70 px-2 py-[3px] text-[10px] leading-none font-semibold tracking-[0.06em] text-white uppercase shadow-sm ring-1 shadow-white/15 backdrop-blur-md transition-colors hover:bg-black/85 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<Library className="h-3 w-3 shrink-0" strokeWidth={2.4} aria-hidden />
<span className="truncate">Library</span>
</Link>
</ViewTransitionLink>
);
}
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import {
SidebarItemNavigationContext,
SidebarItemDetailsReadyContext,
type BeginSidebarItemNavigation,
} from "@/components/sidebarItemNavigationContext";
export default function SidebarItemNavigationProvider({
begin,
itemDetailsReady,
children,
}: {
begin: BeginSidebarItemNavigation;
itemDetailsReady: boolean;
children: ReactNode;
}) {
return (
<SidebarItemNavigationContext.Provider value={begin}>
<SidebarItemDetailsReadyContext.Provider value={itemDetailsReady}>
{children}
</SidebarItemDetailsReadyContext.Provider>
</SidebarItemNavigationContext.Provider>
);
}
@@ -0,0 +1,139 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import SidebarItemNavigationProvider from "./SidebarItemNavigationProvider";
import ViewTransitionLink from "./ViewTransitionLink";
function LocationOutput() {
const location = useLocation();
return <output aria-label="location">{location.pathname}</output>;
}
describe("ViewTransitionLink sidebar navigation", () => {
it("lets Layout intercept an item navigation with its state intact", () => {
const begin = vi.fn(() => true);
render(
<MemoryRouter initialEntries={["/"]}>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1?libraryId=2" state={{ source: "home" }}>
Movie
</ViewTransitionLink>
<LocationOutput />
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
expect(begin).toHaveBeenCalledWith({
href: "/item/movie-1?libraryId=2",
replace: undefined,
state: { source: "home" },
});
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/");
});
it("honors a caller that prevents navigation", () => {
const begin = vi.fn(() => true);
render(
<MemoryRouter>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1" onClick={(event) => event.preventDefault()}>
Movie
</ViewTransitionLink>
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
expect(begin).not.toHaveBeenCalled();
});
it.each([
["meta", { metaKey: true }],
["control", { ctrlKey: true }],
["shift", { shiftKey: true }],
["alt", { altKey: true }],
])("does not intercept a %s-modified click", (_label, init) => {
const begin = vi.fn(() => true);
render(
<MemoryRouter>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...init });
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
expect(begin).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it("does not intercept a middle click", () => {
const begin = vi.fn(() => true);
render(
<MemoryRouter>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
expect(begin).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it("does not intercept a link that opens a new browsing context", () => {
const begin = vi.fn(() => true);
render(
<MemoryRouter>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1" target="_blank">
Movie
</ViewTransitionLink>
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
expect(begin).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it("falls through to router navigation when Layout declines interception", () => {
const begin = vi.fn(() => false);
render(
<MemoryRouter initialEntries={["/"]}>
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
<LocationOutput />
</SidebarItemNavigationProvider>
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
expect(begin).toHaveBeenCalledOnce();
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/item/movie-1");
});
it("behaves as a plain router link without a provider", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
<LocationOutput />
</MemoryRouter>,
);
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/item/movie-1");
});
});
+28 -4
View File
@@ -1,22 +1,46 @@
import { forwardRef } from "react";
import { Link } from "react-router";
import { createPath, Link, useResolvedPath } from "react-router";
import type { LinkProps } from "react-router";
import { useSidebarItemNavigation } from "@/components/sidebarItemNavigationContext";
/**
* A thin wrapper around React Router's `<Link>` that opts into
* the router's built-in view transition support.
* Opts into React Router view transitions and lets the desktop layout prepare
* item-detail navigation so its heavy content cannot overlap sidebar motion.
*/
const ViewTransitionLink = forwardRef<
HTMLAnchorElement,
LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
>(function ViewTransitionLink({ to, replace, state, onClick, children, ...rest }, ref) {
const beginSidebarItemNavigation = useSidebarItemNavigation();
const resolvedPath = useResolvedPath(to);
return (
<Link
ref={ref}
to={to}
replace={replace}
state={state}
onClick={onClick}
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
(rest.target && rest.target !== "_self")
) {
return;
}
const intercepted = beginSidebarItemNavigation?.({
href: createPath(resolvedPath),
replace,
state,
});
if (intercepted) event.preventDefault();
}}
viewTransition
{...rest}
>
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasRunningSidebarTransition,
isCollapsedSidebarSurface,
parseOptionalLibraryId,
parseItemNavigationHref,
SIDEBAR_TRANSITION_FALLBACK_MS,
sidebarDetailsRevealDelay,
} from "./sidebarItemNavigation";
afterEach(() => vi.unstubAllGlobals());
describe("sidebar collapse completion", () => {
it("accepts only the collapsed sidebar surface", () => {
const surface = document.createElement("aside");
surface.classList.add("sidebar-surface");
expect(isCollapsedSidebarSurface(surface)).toBe(false);
surface.dataset.collapsed = "true";
expect(isCollapsedSidebarSurface(surface)).toBe(true);
surface.classList.remove("sidebar-surface");
expect(isCollapsedSidebarSurface(surface)).toBe(false);
});
it("returns false when the Web Animations API is unavailable", () => {
const surface = document.createElement("aside");
Object.defineProperty(surface, "getAnimations", { value: undefined });
expect(hasRunningSidebarTransition(surface)).toBe(false);
});
it("ignores running animations that are not the transform transition", () => {
class FakeTransition {
playState = "running";
constructor(readonly transitionProperty: string) {}
}
vi.stubGlobal("CSSTransition", FakeTransition);
const surface = document.createElement("aside");
Object.defineProperty(surface, "getAnimations", {
value: () => [new FakeTransition("opacity"), { playState: "running" }],
});
expect(hasRunningSidebarTransition(surface)).toBe(false);
});
it("detects a running transform transition", () => {
class FakeTransition {
playState = "running";
transitionProperty = "transform";
}
vi.stubGlobal("CSSTransition", FakeTransition);
const surface = document.createElement("aside");
Object.defineProperty(surface, "getAnimations", {
value: () => [new FakeTransition()],
});
expect(hasRunningSidebarTransition(surface)).toBe(true);
});
});
describe("sidebarDetailsRevealDelay", () => {
it("uses the transition fallback for motion and reveals immediately for reduced motion", () => {
expect(sidebarDetailsRevealDelay(false)).toBe(SIDEBAR_TRANSITION_FALLBACK_MS);
expect(sidebarDetailsRevealDelay(true)).toBe(0);
});
});
describe("parseItemNavigationHref", () => {
it("parses an encoded item id and optional library id", () => {
expect(
parseItemNavigationHref("/item/movie%201%2Fpart?libraryId=12", "http://localhost:5173"),
).toEqual({ contentId: "movie 1/part", libraryId: 12 });
});
it("accepts an item without a library id", () => {
expect(parseItemNavigationHref("/item/movie-1", "http://localhost:5173")).toEqual({
contentId: "movie-1",
libraryId: undefined,
});
});
it("rejects non-item, cross-origin, and malformed destinations", () => {
expect(parseItemNavigationHref("/library/1", "http://localhost:5173")).toBeNull();
expect(
parseItemNavigationHref("https://example.com/item/movie-1", "http://localhost:5173"),
).toBeNull();
expect(parseItemNavigationHref("/item/%E0%A4%A", "http://localhost:5173")).toBeNull();
});
});
describe("parseOptionalLibraryId", () => {
it("accepts finite ids and rejects malformed ones", () => {
expect(parseOptionalLibraryId("12")).toBe(12);
expect(parseOptionalLibraryId("abc")).toBeUndefined();
expect(parseOptionalLibraryId(null)).toBeUndefined();
});
});
@@ -0,0 +1,62 @@
export const SIDEBAR_COLLAPSE_DURATION_MS = 300;
export const SIDEBAR_TRANSITION_FALLBACK_MS = SIDEBAR_COLLAPSE_DURATION_MS + 80;
// Hover expansion and hidden-tab rAF suspension must never hold the detail
// shell indefinitely. Settling is preferred, but this is the absolute cap.
export const SIDEBAR_DETAILS_REVEAL_DEADLINE_MS = SIDEBAR_TRANSITION_FALLBACK_MS * 2;
export function sidebarDetailsRevealDelay(reduceMotion: boolean): number {
return reduceMotion ? 0 : SIDEBAR_TRANSITION_FALLBACK_MS;
}
export function isCollapsedSidebarSurface(target: EventTarget | null): target is HTMLElement {
return (
target instanceof HTMLElement &&
target.classList.contains("sidebar-surface") &&
target.dataset.collapsed === "true"
);
}
export function hasRunningSidebarTransition(surface: HTMLElement): boolean {
if (typeof surface.getAnimations !== "function") return false;
const Transition = globalThis.CSSTransition;
if (typeof Transition === "undefined") return false;
return surface
.getAnimations()
.some(
(animation) =>
animation instanceof Transition &&
animation.transitionProperty === "transform" &&
animation.playState === "running",
);
}
export function parseOptionalLibraryId(rawLibraryId: string | null): number | undefined {
if (!rawLibraryId) return undefined;
const parsedLibraryId = Number(rawLibraryId);
return Number.isFinite(parsedLibraryId) ? parsedLibraryId : undefined;
}
export interface SidebarItemNavigationRequest {
href: string;
replace?: boolean;
state?: unknown;
}
export function parseItemNavigationHref(
href: string,
origin: string,
): { contentId: string; libraryId?: number } | null {
try {
const destination = new URL(href, origin);
if (destination.origin !== origin || !destination.pathname.startsWith("/item/")) return null;
const contentId = decodeURIComponent(destination.pathname.slice("/item/".length));
if (!contentId) return null;
return {
contentId,
libraryId: parseOptionalLibraryId(destination.searchParams.get("libraryId")),
};
} catch {
return null;
}
}
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
import type { SidebarItemNavigationRequest } from "@/components/sidebarItemNavigation";
export type BeginSidebarItemNavigation = (request: SidebarItemNavigationRequest) => boolean;
export const SidebarItemNavigationContext = createContext<BeginSidebarItemNavigation | null>(null);
export const SidebarItemDetailsReadyContext = createContext(true);
export function useSidebarItemNavigation(): BeginSidebarItemNavigation | null {
return useContext(SidebarItemNavigationContext);
}
export function useSidebarItemDetailsReady(): boolean {
return useContext(SidebarItemDetailsReadyContext);
}
@@ -0,0 +1,122 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useImmediateSidebarCollapse } from "./useImmediateSidebarCollapse";
function stubFrames() {
const queue: FrameRequestCallback[] = [];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
queue.push(callback);
return queue.length;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
return {
get pending() {
return queue.length;
},
async frame() {
const callback = queue.shift();
expect(callback, "no frame was requested").toBeTypeOf("function");
await act(async () => callback!(performance.now()));
},
};
}
function stubReducedMotion(reduce: boolean) {
let matches = reduce;
const listeners = new Set<(event: MediaQueryListEvent) => void>();
const mediaQuery = {
get matches() {
return matches;
},
media: "(prefers-reduced-motion: reduce)",
addEventListener(_type: string, listener: (event: MediaQueryListEvent) => void) {
listeners.add(listener);
},
removeEventListener(_type: string, listener: (event: MediaQueryListEvent) => void) {
listeners.delete(listener);
},
addListener() {},
removeListener() {},
onchange: null,
dispatchEvent: () => false,
};
vi.stubGlobal("matchMedia", (query: string) => ({
...mediaQuery,
matches: matches && query.includes("prefers-reduced-motion: reduce"),
media: query,
addEventListener: mediaQuery.addEventListener,
removeEventListener: mediaQuery.removeEventListener,
}));
return {
set(next: boolean) {
matches = next;
const event = { matches: next } as MediaQueryListEvent;
for (const listener of listeners) listener(event);
},
};
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("useImmediateSidebarCollapse", () => {
it("passes the initial state straight through", () => {
stubFrames();
stubReducedMotion(false);
expect(renderHook(() => useImmediateSidebarCollapse(true)).result.current).toBe(true);
});
it("starts the visual collapse on the next frame", async () => {
const frames = stubFrames();
stubReducedMotion(false);
const { result, rerender } = renderHook(
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
{
initialProps: { collapsed: false },
},
);
rerender({ collapsed: true });
expect(result.current).toBe(false);
expect(frames.pending).toBe(1);
await frames.frame();
expect(result.current).toBe(true);
});
it("applies reduced motion without scheduling a frame", () => {
const frames = stubFrames();
stubReducedMotion(true);
const { result, rerender } = renderHook(
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
{
initialProps: { collapsed: false },
},
);
act(() => rerender({ collapsed: true }));
expect(result.current).toBe(true);
expect(frames.pending).toBe(0);
});
it("observes preference changes without a stale catch-up transition", () => {
const frames = stubFrames();
const motion = stubReducedMotion(false);
const { result, rerender } = renderHook(
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
{ initialProps: { collapsed: false } },
);
act(() => motion.set(true));
rerender({ collapsed: true });
expect(result.current).toBe(true);
expect(frames.pending).toBe(0);
act(() => motion.set(false));
expect(result.current).toBe(true);
expect(frames.pending).toBe(0);
});
});
@@ -0,0 +1,48 @@
import { useEffect, useRef, useState } from "react";
export function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
/**
* Separates the layout snap from the visible compositor transition by one
* animation frame. This is the minimum handoff needed for the inverse main
* transform to take effect before the sidebar starts moving; it never waits on
* item metadata, poster decoding, backdrop loading, or frame-rate heuristics.
*/
export function useImmediateSidebarCollapse(collapsed: boolean): boolean {
const [visualCollapsed, setVisualCollapsed] = useState(collapsed);
const [reduceMotion, setReduceMotion] = useState(prefersReducedMotion);
const frameRef = useRef(0);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleChange = (event: MediaQueryListEvent) => {
// Synchronize the internal visual state on both edges so disabling the
// preference cannot play a stale catch-up transition.
setVisualCollapsed(collapsed);
setReduceMotion(event.matches);
};
query.addEventListener("change", handleChange);
return () => query.removeEventListener("change", handleChange);
}, [collapsed]);
useEffect(() => {
if (visualCollapsed === collapsed || reduceMotion) return;
if (typeof requestAnimationFrame !== "function") {
const timer = window.setTimeout(() => setVisualCollapsed(collapsed), 0);
return () => window.clearTimeout(timer);
}
frameRef.current = requestAnimationFrame(() => setVisualCollapsed(collapsed));
return () => cancelAnimationFrame(frameRef.current);
}, [collapsed, reduceMotion, visualCollapsed]);
return reduceMotion ? collapsed : visualCollapsed;
}
@@ -0,0 +1,53 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useSidebarItemDetailsGate } from "./useSidebarItemDetailsGate";
describe("useSidebarItemDetailsGate", () => {
it("gates every non-item to item entry, including re-entering a history entry", () => {
const { result, rerender } = renderHook(
({ locationKey, isItem }: { locationKey: string; isItem: boolean }) =>
useSidebarItemDetailsGate(locationKey, isItem),
{ initialProps: { locationKey: "catalog", isItem: false } },
);
rerender({ locationKey: "movie", isItem: true });
expect(result.current.itemDetailsReady).toBe(false);
act(() => result.current.reveal("movie"));
expect(result.current.itemDetailsReady).toBe(true);
rerender({ locationKey: "catalog", isItem: false });
rerender({ locationKey: "movie", isItem: true });
expect(result.current.itemDetailsReady).toBe(false);
});
it("does not gate a direct initial item render", () => {
const { result } = renderHook(() => useSidebarItemDetailsGate("movie", true));
expect(result.current.itemDetailsReady).toBe(true);
expect(result.current.pendingLocationKey).toBeNull();
});
it("discards an abandoned gate and allows the next item entry", () => {
const { result, rerender } = renderHook(
({ locationKey, isItem }: { locationKey: string; isItem: boolean }) =>
useSidebarItemDetailsGate(locationKey, isItem),
{ initialProps: { locationKey: "catalog", isItem: false } },
);
rerender({ locationKey: "abandoned-movie", isItem: true });
expect(result.current.itemDetailsReady).toBe(false);
rerender({ locationKey: "search", isItem: false });
expect(result.current.itemDetailsReady).toBe(true);
rerender({ locationKey: "next-movie", isItem: true });
expect(result.current.itemDetailsReady).toBe(false);
act(() => result.current.reveal("abandoned-movie"));
expect(result.current.itemDetailsReady).toBe(false);
act(() => result.current.reveal("next-movie"));
expect(result.current.itemDetailsReady).toBe(true);
});
});
@@ -0,0 +1,47 @@
import { useCallback, useState } from "react";
interface ItemDetailsGateState {
locationKey: string;
gatesItemDetails: boolean;
pendingLocationKey: string | null;
}
/**
* Keeps item details lightweight while a desktop route entry collapses the
* sidebar. The gate follows committed route transitions instead of individual
* click handlers, so POP and programmatic navigation receive the same
* treatment and abandoned navigation cannot leave a stale global lock.
*/
export function useSidebarItemDetailsGate(locationKey: string, gatesItemDetails: boolean) {
const [state, setState] = useState<ItemDetailsGateState>({
locationKey,
gatesItemDetails,
pendingLocationKey: null,
});
let currentState = state;
if (state.locationKey !== locationKey || state.gatesItemDetails !== gatesItemDetails) {
currentState = {
locationKey,
gatesItemDetails,
pendingLocationKey: gatesItemDetails && !state.gatesItemDetails ? locationKey : null,
};
// React discards this render and retries before rendering children, so the
// item route never briefly receives `itemDetailsReady=true` on entry.
setState(currentState);
}
const reveal = useCallback((expectedLocationKey: string) => {
setState((current) =>
current.pendingLocationKey === expectedLocationKey
? { ...current, pendingLocationKey: null }
: current,
);
}, []);
return {
itemDetailsReady: !gatesItemDetails || currentState.pendingLocationKey === null,
pendingLocationKey: currentState.pendingLocationKey,
reveal,
};
}
@@ -8,16 +8,23 @@ describe("DetailHero artwork revisions", () => {
const { rerender } = render(<DetailHero title="Blade Runner" posterUrl="/poster.rev-a.webp" />);
const first = screen.getByRole("img", { name: "Blade Runner" });
const firstPlaceholder = screen.getByTestId("detail-hero-poster-placeholder");
expect(first).toHaveClass("opacity-0");
expect(first).not.toHaveClass("transition-opacity");
expect(firstPlaceholder).toHaveClass("opacity-100", "transition-opacity");
fireEvent.load(first);
expect(first).toHaveClass("opacity-100");
expect(firstPlaceholder).toHaveClass("opacity-0");
rerender(<DetailHero title="Blade Runner" posterUrl="/poster.rev-b.webp" />);
const replacement = screen.getByRole("img", { name: "Blade Runner" });
const replacementPlaceholder = screen.getByTestId("detail-hero-poster-placeholder");
expect(replacement).toHaveAttribute("src", "/poster.rev-b.webp");
expect(replacement).toHaveClass("opacity-0");
expect(replacementPlaceholder).toHaveClass("opacity-100");
fireEvent.load(replacement);
expect(replacement).toHaveClass("opacity-100");
expect(replacementPlaceholder).toHaveClass("opacity-0");
});
});
+23 -17
View File
@@ -152,25 +152,31 @@ export default function DetailHero({
{/* Poster */}
{!hidePoster && (
<div
className={`media-card-image border-border/20 border shadow-[var(--shadow-md)] ${posterSizeClass}`}
style={
posterPlaceholder && !posterLoaded
? {
backgroundImage: `url(${posterPlaceholder})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined
}
className={`media-card-image border-border/20 relative border shadow-[var(--shadow-md)] ${posterSizeClass}`}
>
{posterUrl ? (
<img
key={posterUrl}
src={posterUrl}
alt={title}
className={`w-full object-cover ${posterAspect} transition-opacity duration-300 ${posterLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={onPosterLoad}
/>
<>
<img
key={posterUrl}
src={posterUrl}
alt={title}
className={`w-full object-cover ${posterAspect} ${posterLoaded ? "opacity-100" : "opacity-0"}`}
onLoad={onPosterLoad}
/>
<span
key={`placeholder-${posterUrl}`}
aria-hidden="true"
data-testid="detail-hero-poster-placeholder"
className={`bg-surface pointer-events-none absolute inset-0 bg-cover bg-center transition-opacity duration-300 ${
posterLoaded ? "opacity-0" : "opacity-100"
}`}
style={
posterPlaceholder
? { backgroundImage: `url(${posterPlaceholder})` }
: undefined
}
/>
</>
) : (
<div
className={`text-muted-foreground bg-surface flex items-center justify-center p-6 text-center text-sm ${posterAspect}`}
+23 -1
View File
@@ -4,11 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
useCatalogItemDetail: vi.fn(),
toastError: vi.fn(),
search: "",
}));
vi.mock("react-router", () => ({
useParams: () => ({ id: "movie-123" }),
useSearchParams: () => [new URLSearchParams()],
useSearchParams: () => [new URLSearchParams(mocks.search)],
}));
vi.mock("@/hooks/queries/catalogRead", () => ({
@@ -46,11 +47,13 @@ vi.mock("@/pages/ItemDetail/EbookContent", () => ({
}));
import ItemDetail from "./index";
import { SidebarItemDetailsReadyContext } from "@/components/sidebarItemNavigationContext";
describe("ItemDetail", () => {
beforeEach(() => {
mocks.useCatalogItemDetail.mockReset();
mocks.toastError.mockReset();
mocks.search = "";
mocks.useCatalogItemDetail.mockReturnValue({
data: { content_id: "movie-123", title: "Catalog Detail", type: "movie" },
isLoading: false,
@@ -58,6 +61,14 @@ describe("ItemDetail", () => {
});
});
it("ignores a malformed library id so the detail query matches the prefetch key", () => {
mocks.search = "libraryId=abc";
renderToStaticMarkup(<ItemDetail />);
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123", undefined);
});
it("reads item detail through the canonical catalog detail hook", () => {
const markup = renderToStaticMarkup(<ItemDetail />);
@@ -65,6 +76,17 @@ describe("ItemDetail", () => {
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123", undefined);
});
it("keeps the lightweight shell mounted until the sidebar transition finishes", () => {
const markup = renderToStaticMarkup(
<SidebarItemDetailsReadyContext.Provider value={false}>
<ItemDetail />
</SidebarItemDetailsReadyContext.Provider>,
);
expect(markup).not.toContain("Catalog Detail");
expect(markup).toContain("animate-pulse");
});
it("routes ebook items to ebook detail content", () => {
mocks.useCatalogItemDetail.mockReturnValue({
data: { content_id: "ebook-123", title: "A Psalm for the Wild-Built", type: "ebook" },
+5 -3
View File
@@ -17,6 +17,8 @@ import {
CrewSkeleton,
RecommendationGridSkeleton,
} from "./components/SectionSkeletons";
import { useSidebarItemDetailsReady } from "@/components/sidebarItemNavigationContext";
import { parseOptionalLibraryId } from "@/components/sidebarItemNavigation";
function ItemDetailSkeleton() {
return (
@@ -72,9 +74,9 @@ function ItemDetailSkeleton() {
export default function ItemDetail() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const libraryIdParam = searchParams.get("libraryId");
const libraryId = libraryIdParam ? Number(libraryIdParam) : undefined;
const libraryId = parseOptionalLibraryId(searchParams.get("libraryId"));
const { data: item, isLoading: loading, error: itemError } = useCatalogItemDetail(id, libraryId);
const itemDetailsReady = useSidebarItemDetailsReady();
useDocumentTitle(item?.title ?? "Item");
@@ -84,7 +86,7 @@ export default function ItemDetail() {
}
}, [itemError]);
if (loading) {
if (loading || !itemDetailsReady) {
return <ItemDetailSkeleton />;
}
+3 -3
View File
@@ -1,5 +1,4 @@
import { Fragment, useState } from "react";
import { Link } from "react-router";
import { Bell, BellOff, Check, CheckCheck, Loader2, Settings2 } from "lucide-react";
import type { AppNotification } from "@/api/types";
import { Button } from "@/components/ui/button";
@@ -19,6 +18,7 @@ import {
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { decodeThumbhash } from "@/lib/thumbhash";
import { preferredDateLocale } from "@/lib/datetime";
import ViewTransitionLink from "@/components/ViewTransitionLink";
function formatNotificationTime(value: string): string {
const date = new Date(value);
@@ -180,7 +180,7 @@ function NotificationRow({
return (
<li className="group relative">
{detailHref ? (
<Link
<ViewTransitionLink
to={detailHref}
onClick={() => unread && onMarkRead(notification.id)}
className={`hover:bg-muted/60 flex items-start gap-3 rounded-xl px-3 py-3 transition-colors ${
@@ -188,7 +188,7 @@ function NotificationRow({
}`}
>
{body}
</Link>
</ViewTransitionLink>
) : (
<div
className={`flex items-start gap-3 rounded-xl px-3 py-3 ${unread ? "bg-muted/30" : ""}`}
+4 -3
View File
@@ -1,4 +1,4 @@
import { Link, useParams } from "react-router";
import { useParams } from "react-router";
import { Check, Clock, Library, Loader2, Plus, Star } from "lucide-react";
import CastCarousel from "@/components/CastCarousel";
import MediaCarousel from "@/components/MediaCarousel";
@@ -22,6 +22,7 @@ import {
requestInputFromMediaResult,
tmdbImageURL,
} from "@/lib/mediaRequests";
import ViewTransitionLink from "@/components/ViewTransitionLink";
export default function RequestDetail() {
const params = useParams<{ mediaType: string; tmdbId: string }>();
@@ -239,10 +240,10 @@ function RequestActions({
variant="outline"
className="border-border/60 h-11 rounded-full px-5 text-sm font-semibold"
>
<Link to={`/item/${encodeURIComponent(item.library_content_id)}`}>
<ViewTransitionLink to={`/item/${encodeURIComponent(item.library_content_id)}`}>
<Library className="h-4 w-4" />
Open in library
</Link>
</ViewTransitionLink>
</Button>
) : null}
</>
+183
View File
@@ -0,0 +1,183 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { SIDEBAR_COLLAPSE_DURATION_MS } from "./components/sidebarItemNavigation";
import { SIDEBAR_RAIL_WIDTH, SIDEBAR_SURFACE_WIDTH } from "./components/AppSidebar.logic";
/**
* The sidebar-collapse behaviour lives mostly in CSS, so these are contract
* tests over app.css rather than over rendered markup.
*
* The rules that matter: the sidebar surface stays 260px wide in every state,
* entering /item/* snaps the real layout from a 260px to a 64px offset in one
* pass, and the only animated property is `transform`. Transform is the one
* choice Chrome runs on the compositor — a clipping-path version of this was
* swallowed whole by the detail page's render (267ms and 209ms frozen frames).
*/
const css = readFileSync(fileURLToPath(new URL("./app.css", import.meta.url)), "utf8");
/**
* Body of the rule whose selector list starts at `selector`, searched from
* after the reduced-motion block so the `transition: none` overrides in there
* are never mistaken for the real declarations.
*/
function ruleBody(selector: string): string {
// This anchor is intentionally after reduced motion, whose selector list
// also contains `.sidebar-main-stage` but only declares transition: none.
const from = css.indexOf("@media (forced-colors: active)");
expect(from, "forced-colors anchor must remain after reduced motion").toBeGreaterThan(-1);
const start = css.indexOf(selector, from);
expect(start, `missing rule: ${selector}`).toBeGreaterThan(-1);
return css.slice(css.indexOf("{", start), css.indexOf("}", start));
}
/** Body of the first `@media (prefers-reduced-motion: reduce)` block. */
function reducedMotionBlock(): string {
const start = css.indexOf("@media (prefers-reduced-motion: reduce)");
expect(start).toBeGreaterThan(-1);
let depth = 0;
for (let i = css.indexOf("{", start); i < css.length; i++) {
if (css[i] === "{") depth++;
else if (css[i] === "}" && --depth === 0) return css.slice(start, i + 1);
}
throw new Error("unterminated prefers-reduced-motion block");
}
describe("sidebar collapse CSS", () => {
it("animates transform and nothing else on the surface", () => {
const surface = ruleBody(".sidebar-surface {");
expect(surface).toContain("overflow: hidden");
expect(surface).toContain("transform: translateX(0)");
expect(surface).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
// Any layout property here would put the document back on the reflow path,
// and any non-composited property would be starved by the route render.
expect(surface).not.toMatch(/\b(width|margin-left|left|clip-path|grid-template-columns)\s*:/);
});
it("slides the frame left by exactly the 196px it has to give up", () => {
const travel = SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH;
expect(ruleBody('.sidebar-surface[data-collapsed="true"] {')).toContain(
`transform: translateX(-${travel}px)`,
);
});
it("counter-translates the contents by the same distance so nothing moves on screen", () => {
const inner = ruleBody(".sidebar-inner {");
expect(inner).toContain("transform: translateX(0)");
expect(inner).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-inner {')).toContain(
`transform: translateX(${SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH}px)`,
);
});
it("moves the main stage on the same compositor timeline as the sidebar", () => {
const main = ruleBody(".sidebar-main-stage {");
expect(main).toContain("transform: none");
expect(main).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
const entering = ruleBody('.sidebar-main-stage[data-sidebar-target-collapsed="true"]:not(');
expect(entering).toContain("transform: translateX(196px)");
expect(entering).toContain("transition: none");
expect(css).toMatch(
/\.sidebar-main-stage:not\([\s\S]*?data-sidebar-target-collapsed="true"[\s\S]*?\)\[data-sidebar-visual-collapsed="true"\]\s*\{\s*transform:\s*translateX\(-196px\);\s*transition:\s*none/,
);
});
it("uses the shared collapse duration on a compositor-friendly ease", () => {
const duration = css.match(/--duration-sidebar-collapse:\s*(\d+)ms/);
expect(duration).not.toBeNull();
expect(Number(duration![1])).toBe(SIDEBAR_COLLAPSE_DURATION_MS);
expect(css).toMatch(/--ease-sidebar-collapse:\s*cubic-bezier\(/);
});
it("cross-fades and translates sidebar contents rather than resizing them", () => {
expect(ruleBody(".sidebar-fade {")).toMatch(
/transition:\s*opacity var\(--duration-sidebar-collapse\)/,
);
expect(ruleBody(".sidebar-row-shift {")).toMatch(
/transition:\s*transform var\(--duration-sidebar-collapse\)/,
);
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-row-shift {')).toContain(
"transform: translateX(var(--sidebar-row-shift, 0px))",
);
});
it("no longer animates any layout property, or any main-thread-only one", () => {
// The old `.sidebar-transition` / `.main-transition` pair is gone; nothing
// in the sidebar or the main column interpolates geometry any more.
expect(css).not.toContain(".sidebar-transition");
expect(css).not.toContain(".main-transition");
expect(css).not.toMatch(/transition:\s*margin-left/);
expect(css).not.toMatch(/transition:\s*clip-path/);
// The bespoke sidebar View Transition snapshot rules are gone.
expect(css).not.toContain("app-sidebar)");
expect(css).not.toContain("data-sidebar-collapse-transition");
expect(css).not.toContain(":active-view-transition");
});
it("makes reduced motion an instant state change", () => {
const block = reducedMotionBlock();
expect(block).toContain("--duration-sidebar-collapse: 0ms");
for (const selector of [
".sidebar-surface,",
".sidebar-inner,",
".sidebar-fade,",
".sidebar-row-shift,",
".sidebar-main-stage,",
".impersonation-banner",
]) {
expect(block).toContain(selector);
}
});
it("snaps the impersonation banner's margin instead of animating it", () => {
const banner = ruleBody(".impersonation-banner {");
expect(banner).toContain("margin-left: var(--app-sidebar-offset)");
// Animating margin would put the document back on the per-frame layout path.
expect(banner).not.toContain("transition: margin-left");
expect(banner).not.toMatch(/transition:[^;]*\b(margin|width|left)\b/);
});
it("carries the impersonation banner's edge with a compensating transform", () => {
// The banner renders outside Layout, so it mirrors `.sidebar-main-stage`
// off root attributes: hold the previous visual offset with no transition,
// then release to zero over the shared sidebar duration/easing.
expect(ruleBody(".impersonation-banner {")).not.toContain("transform:");
const entering = ruleBody(
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
);
expect(entering).toContain("transform: translateX(196px)");
expect(entering).toContain("transition: none");
const leaving = ruleBody(
':root:not([data-sidebar-collapsed="true"])[data-sidebar-visual-collapsed="true"]',
);
expect(leaving).toContain("transform: translateX(-196px)");
expect(leaving).toContain("transition: none");
// The settled rule animates transform on the same tokens as the sidebar.
expect(css).toMatch(
/\.impersonation-banner \{\s*transition: transform var\(--duration-sidebar-collapse\) var\(--ease-sidebar-collapse\);/,
);
});
it("compensates the banner on the same breakpoint that moves its margin", () => {
// `html[data-text-scale]` re-bases rem, so 64rem and 1024px diverge at large
// text. The compensation must follow `--app-sidebar-offset`'s own query.
const nearestQueryBefore = (needle: string) => {
const at = css.indexOf(needle);
expect(at, `missing: ${needle}`).toBeGreaterThan(-1);
const queryAt = css.lastIndexOf("@media", at);
return css.slice(queryAt, css.indexOf("{", queryAt)).trim();
};
expect(nearestQueryBefore("--app-sidebar-offset: 64px")).toBe("@media (min-width: 1024px)");
expect(
nearestQueryBefore(
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
),
).toBe("@media (min-width: 1024px)");
});
});