The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
|
import { useScrollFade } from "../../lib/useScrollFade";
|
|
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
|
import { IconButton } from "./IconButton";
|
|
import { Pressable } from "./Pressable";
|
|
import Modal from "./Modal";
|
|
import { SURFACE } from "./surfaces";
|
|
|
|
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
|
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
|
// against the rail's right edge (this panel is the flex sibling right after it) and expands into a
|
|
// rounded glass card on hover/focus WITHOUT pushing content; pinned it stays expanded in-flow.
|
|
// `collapsed` (persisted per-account) therefore means "unpinned".
|
|
//
|
|
// On touch, and in a window below 768 (C2), there is no hover to peek with and no room to pin: the tab
|
|
// (shown at every width) opens the panel as a DRAWER over the content, with a scrim — ui/Modal's
|
|
// start-edge drawer, so Esc, Back, a tap outside, the focus trap and the scroll lock are the dialog's
|
|
// own. Pinning stays a button in the panel's header, offered only ≥ 1024 where a pinned panel leaves
|
|
// the content room; a pin saved on a desktop is left alone below that, not honoured.
|
|
|
|
// Where the panel is a drawer rather than a peeking card: a coarse pointer, or a window too narrow to
|
|
// lay a panel beside the content. And where a pinned panel still leaves the content room (≥ 1024).
|
|
const DRAWER_QUERY = "(pointer: coarse), (max-width: 767.98px)";
|
|
const PIN_ROOM_QUERY = "(min-width: 1024px)";
|
|
|
|
export default function SidePanel({
|
|
title,
|
|
icon,
|
|
count = 0,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
editing = false,
|
|
onToggleEditing,
|
|
onReset,
|
|
editHint,
|
|
actions,
|
|
iconExpanded,
|
|
onTitleClick,
|
|
titleActionLabel,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
icon: ReactNode;
|
|
count?: number;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
// Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely.
|
|
editing?: boolean;
|
|
onToggleEditing?: () => void;
|
|
onReset?: () => void;
|
|
editHint?: string;
|
|
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
|
|
actions?: ReactNode;
|
|
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
|
|
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
|
|
iconExpanded?: ReactNode;
|
|
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
|
|
onTitleClick?: () => void;
|
|
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
|
|
titleActionLabel?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const cardRef = useRef<HTMLElement>(null);
|
|
const tabRef = useRef<HTMLButtonElement>(null);
|
|
const { ref: fadeRef, style: fadeStyle } = useScrollFade();
|
|
const peek = useHoverFocusWithin();
|
|
const drawerMode = useMediaQuery(DRAWER_QUERY, false);
|
|
const pinRoom = useMediaQuery(PIN_ROOM_QUERY, true);
|
|
const canPin = !drawerMode || pinRoom;
|
|
const pinned = !collapsed && canPin;
|
|
// A drawer never peeks: a tap would fire the emulated hover and open the card under the finger.
|
|
const expanded = pinned || (!drawerMode && peek.active);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
// Turning the tablet to a width where the panel is a card again, or pinning it, ends the drawer —
|
|
// adjusted during render, so it cannot come back open on the next turn.
|
|
if (drawerOpen && (!drawerMode || pinned)) setDrawerOpen(false);
|
|
// A key landing on the tab peeks the card open over it, and the tab then goes inert below — with
|
|
// the focus still on it: a hidden control, out of the tree, that Enter still pinned.
|
|
// The focus steps into the card as it opens, onto its pin: the same control. In the commit that
|
|
// mounts the card, not a frame later — a frame never comes in a page the browser is not painting.
|
|
useLayoutEffect(() => {
|
|
const tab = tabRef.current;
|
|
if (!expanded || pinned || drawerMode || !tab || document.activeElement !== tab) return;
|
|
if (tab.matches(":focus-visible"))
|
|
cardRef.current?.querySelector<HTMLElement>("button")?.focus();
|
|
}, [expanded, pinned, drawerMode]);
|
|
|
|
const titleNode = onTitleClick ? (
|
|
<Pressable
|
|
onClick={onTitleClick}
|
|
title={titleActionLabel}
|
|
aria-label={titleActionLabel}
|
|
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
|
>
|
|
{title}
|
|
</Pressable>
|
|
) : (
|
|
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
|
{title}
|
|
</span>
|
|
);
|
|
const countBadge =
|
|
count > 0 ? (
|
|
<span
|
|
title={t("sidebar.activeCount", { count })}
|
|
className="shrink-0 min-w-4.5 h-4.5 px-1.5 rounded-full bg-accent/15 text-accent text-2xs font-semibold inline-flex items-center justify-center tabular-nums"
|
|
>
|
|
{count}
|
|
</span>
|
|
) : null;
|
|
const editButtons = (
|
|
<>
|
|
{editing && onReset && (
|
|
<IconButton label={t("sidebar.resetDefaults")} size="sm" variant="ghost" onClick={onReset}>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</IconButton>
|
|
)}
|
|
{onToggleEditing && (
|
|
<IconButton
|
|
variant={editing ? "ghost-accent" : "ghost"}
|
|
onClick={onToggleEditing}
|
|
label={editing ? t("sidebar.done") : t("sidebar.customize")}
|
|
>
|
|
{editing ? <Check /> : <Pencil />}
|
|
</IconButton>
|
|
)}
|
|
</>
|
|
);
|
|
const body = (
|
|
<>
|
|
{editing && editHint && <div className="text-2xs text-muted">{editHint}</div>}
|
|
{children}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
// Layout slot: reserves only the tab's gutter when unpinned, so the expanded card overlays
|
|
// content instead of pushing it. Pinned reserves --rail-pinned-w: 16.75rem (in-flow, pushes) on a
|
|
// full-width page or a narrow window, but 2.25rem (overlay, doesn't push) when the centred content
|
|
// leaves room for the rail to sit in its left margin — see PageShell. Default 16.75rem = old w-67.
|
|
<div
|
|
// Focus is tracked container-wide (keyboard: tab anywhere inside holds it open). HOVER is NOT
|
|
// on this full-height slot — that made the whole column next to the nav rail a live target, so
|
|
// merely approaching the rail popped the panel open. Hover now lives only on the visible tab
|
|
// handle + the expanded card below, so only pointing AT the tab opens it.
|
|
{...(drawerMode ? {} : peek.focusHandlers)}
|
|
className={`${drawerMode ? "block" : "hidden md:block"} relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9 pointer-coarse:w-11"}`}
|
|
>
|
|
{/* The tab: a small handle flush against the nav rail, flat on the docked edge. It stays
|
|
mounted while the card is open — swapping it out from under the pointer would drop the very
|
|
hover that opened the card, and unmounting it while focused would throw keyboard focus to
|
|
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
|
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
|
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable, and a focus already on it stays.
|
|
But no Tab lands on it while the card covers it: an invisible stop, where the
|
|
card's own pin says the same. A keyboard pin hands the focus into the card it opened. */}
|
|
<Pressable
|
|
{...(drawerMode ? {} : peek.hoverHandlers)}
|
|
onClick={
|
|
drawerMode
|
|
? () => setDrawerOpen(true)
|
|
: (e) => {
|
|
onToggleCollapse();
|
|
if (e.detail === 0)
|
|
requestAnimationFrame(() =>
|
|
cardRef.current?.querySelector<HTMLElement>("button")?.focus(),
|
|
);
|
|
}
|
|
}
|
|
ref={tabRef}
|
|
// Under an open card the tab is transparent and inert: out of the Tab order AND of the
|
|
// accessibility tree, where it would repeat the card's own pin button by name.
|
|
tabIndex={expanded ? -1 : undefined}
|
|
aria-hidden={expanded || undefined}
|
|
aria-label={
|
|
drawerMode
|
|
? t("sidebar.openPanel", { title })
|
|
: pinned
|
|
? t("sidebar.unpinNamed", { title })
|
|
: t("sidebar.pinNamed", { title })
|
|
}
|
|
aria-haspopup={drawerMode ? "dialog" : undefined}
|
|
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 pointer-coarse:w-11 transition-opacity ${
|
|
expanded ? "opacity-0" : ""
|
|
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
|
|
>
|
|
<span className="text-accent">{icon}</span>
|
|
{count > 0 && (
|
|
<span className="bg-accent text-accent-fg text-3xs font-bold px-1.5 py-0.5 rounded-full tabular-nums">
|
|
{count > 9 ? "9+" : count}
|
|
</span>
|
|
)}
|
|
<span
|
|
className="text-xs font-semibold uppercase tracking-[0.18em] text-muted"
|
|
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
|
>
|
|
{title}
|
|
</span>
|
|
</Pressable>
|
|
|
|
{expanded && (
|
|
<aside
|
|
ref={cardRef}
|
|
{...peek.hoverHandlers}
|
|
className={`${SURFACE.panel} absolute top-3 bottom-3 left-0 z-panel w-64 flex flex-col overflow-hidden`}
|
|
>
|
|
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
|
|
Module actions live on their own row below so the title always has room. */}
|
|
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
|
<IconButton
|
|
label={pinned ? t("sidebar.unpinNamed", { title }) : t("sidebar.pinNamed", { title })}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onToggleCollapse}
|
|
className="-ml-1"
|
|
>
|
|
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
|
</IconButton>
|
|
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
|
{titleNode}
|
|
{countBadge}
|
|
{editButtons}
|
|
</div>
|
|
|
|
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
|
{!editing && actions && (
|
|
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
|
{actions}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={fadeRef}
|
|
style={fadeStyle}
|
|
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
|
|
>
|
|
<div className="p-3 space-y-3">{body}</div>
|
|
</div>
|
|
</aside>
|
|
)}
|
|
|
|
{drawerOpen && (
|
|
<Modal
|
|
placement="drawer-start"
|
|
maxWidth="max-w-80"
|
|
initialFocus="close"
|
|
onClose={() => setDrawerOpen(false)}
|
|
title={
|
|
<span className="flex items-center gap-1.5">
|
|
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
|
{titleNode}
|
|
{countBadge}
|
|
</span>
|
|
}
|
|
headerActions={
|
|
<>
|
|
{pinRoom && (
|
|
<IconButton
|
|
label={t("sidebar.pinNamed", { title })}
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setDrawerOpen(false);
|
|
onToggleCollapse();
|
|
}}
|
|
>
|
|
<Pin className="w-4 h-4" />
|
|
</IconButton>
|
|
)}
|
|
{editButtons}
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
{!editing && actions && (
|
|
<div className="flex items-center justify-between gap-2 border-b border-border/60 pb-2">
|
|
{actions}
|
|
</div>
|
|
)}
|
|
{body}
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|