feat(design): side panels open as a drawer on touch and below 768 (E28 S3f3)

C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.

DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
This commit is contained in:
2026-09-27 04:38:42 +02:00
parent a58a398427
commit ef343dbec0
8 changed files with 323 additions and 63 deletions
+12 -1
View File
@@ -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 |
+16 -4
View File
@@ -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,6 +164,8 @@ export default function Modal({
className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer
? "justify-end"
: 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"
@@ -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({
<h2 id={titleId} className="text-lg font-semibold leading-snug">
{title}
</h2>
{headerActions && (
<div className="-my-1 ml-auto flex shrink-0 items-center gap-1">{headerActions}</div>
)}
<IconButton
ref={closeRef}
label={t("common.close")}
@@ -0,0 +1,144 @@
// @vitest-environment jsdom
//
// C2 (DESIGN "Touch"): on touch, and below 768, the side panel is a DRAWER — the tab opens it over the
// content (ui/Modal's start-edge drawer: Esc, Back, the scrim), it never peeks on a tap's emulated
// hover, and pinning is a button in its header offered only ≥ 1024. A mouse on a wide window keeps
// hover-to-peek and click-to-pin. jsdom has no matchMedia: the stub answers the two queries SidePanel
// asks from three switches, and can flip them live (a tablet turning).
import { describe, it, expect, afterEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (k: string, o?: Record<string, unknown>) => (o?.title ? `${k}:${String(o.title)}` : k),
}),
};
});
import SidePanel from "./SidePanel";
type Env = { coarse: boolean; narrow: boolean; wide: boolean };
let env: Env = { coarse: false, narrow: false, wide: true };
const listeners = new Set<() => void>();
function stubMedia(next: Env) {
env = next;
window.matchMedia = ((query: string) => ({
get matches() {
// A comma list is an OR: each part answers from its own switch.
return query
.split(",")
.some((q) =>
q.includes("pointer: coarse")
? env.coarse
: q.includes("max-width: 767.98px")
? env.narrow
: q.includes("min-width: 1024px")
? env.wide
: false,
);
},
media: query,
addEventListener: (_: string, l: () => void) => listeners.add(l),
removeEventListener: (_: string, l: () => void) => listeners.delete(l),
})) as unknown as typeof window.matchMedia;
}
function turn(next: Env) {
act(() => {
env = next;
for (const l of [...listeners]) l();
});
}
afterEach(() => {
cleanup();
listeners.clear();
// @ts-expect-error — jsdom ships none; put it back the way it was
delete window.matchMedia;
});
function mount(collapsed = true) {
const onToggleCollapse = vi.fn();
render(
<SidePanel
title="Filters"
icon={<span />}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
>
<p>panel body</p>
</SidePanel>,
);
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");
});
});
+133 -54
View File
@@ -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 ? (
<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
@@ -65,8 +135,8 @@ export default function SidePanel({
// 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.
{...peek.focusHandlers}
className={`hidden md:block relative shrink-0 transition-[width] ${pinned ? "w-[var(--rail-pinned-w,16.75rem)]" : "w-9"}`}
{...(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
@@ -75,10 +145,17 @@ export default function SidePanel({
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
<Pressable
{...peek.hoverHandlers}
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`${SURFACE.edgeTab} glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 transition-opacity ${
{...(drawerMode ? {} : peek.hoverHandlers)}
onClick={drawerMode ? () => setDrawerOpen(true) : onToggleCollapse}
aria-label={
drawerMode
? t("sidebar.openPanel", { title })
: pinned
? t("sidebar.unpin")
: t("sidebar.pin")
}
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" : ""}`}
>
@@ -114,47 +191,9 @@ export default function SidePanel({
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</IconButton>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{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>
)}
{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>
)}
{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>
)}
{titleNode}
{countBadge}
{editButtons}
</div>
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
@@ -169,13 +208,53 @@ export default function SidePanel({
style={fade.style}
className="flex-1 min-h-0 overflow-y-auto no-scrollbar"
>
<div className="p-3 space-y-3">
{editing && editHint && <div className="text-2xs text-muted">{editHint}</div>}
{children}
</div>
<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.pin")}
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>
);
}
+2
View File
@@ -11,6 +11,8 @@ export const SURFACE = {
panel: "glass rounded-2xl",
/** A dialog that slides in from the edge: square on a phone, r16 on its open side. */
drawer: "glass rounded-none sm:rounded-l-2xl",
/** The same, from the START edge (a side panel on touch, C2): r16 on its open side. */
drawerStart: "glass rounded-none sm:rounded-r-2xl",
/** A card or a row card in the page (a video, a user, a download): r12. */
card: "glass-card rounded-xl",
/** A menu or a listbox: r12, 4px padding, so its r8 rows nest (12 − 4). */
@@ -13,6 +13,7 @@
"collapse": "Collapse",
"pin": "Pin panel open",
"unpin": "Unpin panel",
"openPanel": "Open {{title}}",
"any": "Any",
"all": "All",
"from": "From",
@@ -13,6 +13,7 @@
"collapse": "Összecsukás",
"pin": "Panel rögzítése nyitva",
"unpin": "Panel rögzítésének feloldása",
"openPanel": "{{title}} megnyitása",
"any": "Bármelyik",
"all": "Összes",
"from": "Ettől",
+11 -1
View File
@@ -895,8 +895,18 @@ html[data-perf="1"] .overlay-backdrop {
opacity: 0;
}
}
.overlay-drawer-start {
animation: drawer-in-start 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in-start {
from {
transform: translateX(-1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-drawer {
.overlay-drawer,
.overlay-drawer-start {
animation: none;
}
}