diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 4fb1e656..a2981ead 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -63,7 +63,15 @@ style guide. or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and - click-to-pin. A filter is never unreachable at any supported width. [Nielsen #7, WCAG 1.4.10] + click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel` + opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll + lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone + below 1024, not honoured. [Nielsen #7, WCAG 1.4.10] +- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the + playlist table, the Channels tag row) stacks by a container query on its own box, not by the + window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A + line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its + end, never a flex row that breaks its pieces apart. - **Players on touch (C3):** see [Players](#players). ## Tokens @@ -553,3 +561,6 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen # | 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it | | 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line | | 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture | +| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift | +| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart | +| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint | diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index bc19d888..31882805 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -61,19 +61,23 @@ export default function Modal({ maxWidth = "max-w-lg", placement = "center", initialFocus, + headerActions, }: { title: ReactNode; initialFocus: ModalFocus; onClose: () => void; children: ReactNode; maxWidth?: string; - /** `drawer` slides in from the right edge and runs full height; `top` pins the panel near the top + /** `drawer` slides in from the right edge and runs full height (`drawer-start`: from the left, where + * a side panel lives); `top` pins the panel near the top * of the viewport (where a search-first dialog like the command palette belongs, so the list grows * downwards instead of the whole thing jumping as results change). Everything else about the shell * — the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A * placement is not a second dialog implementation: duplicating that behaviour is exactly how two * shells drift apart. */ - placement?: "center" | "drawer" | "top"; + placement?: "center" | "drawer" | "drawer-start" | "top"; + /** Controls beside the ✕ in the header (a side panel's pin and customize, C2). */ + headerActions?: ReactNode; }) { const { t } = useTranslation(); // Browser/mouse Back closes the topmost modal instead of navigating away. @@ -150,6 +154,7 @@ export default function Modal({ }, []); const drawer = placement === "drawer"; + const drawerStart = placement === "drawer-start"; const top = placement === "top"; // Only on a server render, where the hook has no `document` to build a host from. if (!host) return null; @@ -159,9 +164,11 @@ export default function Modal({ className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${ drawer ? "justify-end" - : top - ? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]" - : "items-center justify-center p-4 sm:p-6" + : drawerStart + ? "justify-start" + : top + ? "items-start justify-center p-4 pt-[8dvh] sm:p-6 sm:pt-[10dvh]" + : "items-center justify-center p-4 sm:p-6" }`} onMouseDown={(e) => { pressedOnBackdrop.current = e.target === e.currentTarget; @@ -178,8 +185,10 @@ export default function Modal({ tabIndex={-1} className={ drawer - ? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full rounded-none sm:rounded-l-2xl overlay-drawer` - : `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full rounded-2xl` + ? `${SURFACE.drawer} ${PANEL} w-full ${maxWidth} h-full overlay-drawer` + : drawerStart + ? `${SURFACE.drawerStart} ${PANEL} w-full ${maxWidth} h-full overlay-drawer-start` + : `${SURFACE.panel} ${PANEL} w-full ${maxWidth} max-h-full` } > {/* C4: the header and the footer stay put and only the body scrolls, with the B9 fade at @@ -189,6 +198,9 @@ export default function Modal({
panel body
+ , + ); + return { onToggleCollapse }; +} + +describe("SidePanel · a mouse on a wide window", () => { + it("pins from the tab, as before", () => { + stubMedia({ coarse: false, narrow: false, wide: true }); + const { onToggleCollapse } = mount(); + fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" })); + expect(onToggleCollapse).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); + +describe("SidePanel · on touch", () => { + it("opens a drawer from the tab instead of pinning, and Esc closes it", () => { + stubMedia({ coarse: true, narrow: false, wide: false }); + const { onToggleCollapse } = mount(); + expect(screen.queryByText("panel body")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); + const dialog = screen.getByRole("dialog"); + expect(dialog.textContent).toContain("panel body"); + expect(onToggleCollapse).not.toHaveBeenCalled(); + // Below 1024 a pinned panel would leave the content no room: no pin in the drawer. + expect(screen.queryByRole("button", { name: "sidebar.pin" })).toBeNull(); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("never peeks on the emulated hover a tap fires", () => { + stubMedia({ coarse: true, narrow: false, wide: false }); + mount(); + fireEvent.mouseEnter(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); + expect(screen.queryByText("panel body")).toBeNull(); + }); + + it("offers the pin in the drawer's header at 1024 and up, and pinning closes the drawer", () => { + stubMedia({ coarse: true, narrow: false, wide: true }); + const { onToggleCollapse } = mount(); + fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); + fireEvent.click(screen.getByRole("button", { name: "sidebar.pin" })); + expect(onToggleCollapse).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("leaves a pin saved on a desktop alone below 1024: a tab, not a pinned card", () => { + stubMedia({ coarse: true, narrow: false, wide: false }); + mount(false); + expect(screen.queryByText("panel body")).toBeNull(); + expect(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })).toBeTruthy(); + }); + + it("closes for good when the tablet turns to a width where the panel is a card again", () => { + stubMedia({ coarse: false, narrow: true, wide: false }); + mount(); + fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); + expect(screen.getByRole("dialog")).toBeTruthy(); + turn({ coarse: false, narrow: false, wide: true }); + expect(screen.queryByRole("dialog")).toBeNull(); + turn({ coarse: false, narrow: true, wide: false }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); + +describe("SidePanel · a narrow window with a mouse", () => { + it("is a drawer too (split screen, a small window)", () => { + stubMedia({ coarse: false, narrow: true, wide: false }); + mount(); + fireEvent.click(screen.getByRole("button", { name: "sidebar.openPanel:Filters" })); + expect(screen.getByRole("dialog").textContent).toContain("panel body"); + }); +}); diff --git a/frontend/src/components/ui/SidePanel.tsx b/frontend/src/components/ui/SidePanel.tsx index e0ef7ad9..1648588c 100644 --- a/frontend/src/components/ui/SidePanel.tsx +++ b/frontend/src/components/ui/SidePanel.tsx @@ -1,10 +1,12 @@ -import { type ReactNode } from "react"; +import { 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 @@ -12,6 +14,18 @@ import { SURFACE } from "./surfaces"; // 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, @@ -52,8 +66,64 @@ export default function SidePanel({ const { t } = useTranslation(); const fade = useScrollFade(); const peek = useHoverFocusWithin(); - const pinned = !collapsed; - const expanded = pinned || peek.active; + 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); + + const titleNode = onTitleClick ? ( +