From 1199ea5866e0556c3558c6ac13a591266add00c7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:36:38 -0700 Subject: [PATCH] CL-5860: responsive shell layout Contextual panel becomes an overlay drawer below the narrow breakpoint (<700px) instead of being hidden, the rail collapses to icon-only, and the canvas/column visibility is unchanged. Adds breakpoint predicates (railShowLabels, contextualPanelIsDrawer) with unit tests covering all three modes and the 1280/1024/600 width boundaries. Closes CL-5860 --- apps/web/src/app.css | 76 ++++++++++++++++++++++++++ apps/web/src/shell/app-shell.tsx | 49 ++++++++++++++++- apps/web/src/shell/breakpoints.test.ts | 60 ++++++++++++++++++++ apps/web/src/shell/breakpoints.ts | 21 +++++++ apps/web/src/shell/rail.tsx | 4 +- 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/shell/breakpoints.test.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 38feb7cfe..ee4634cb8 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -219,6 +219,82 @@ `prefers-reduced-motion` in its theme.css), and these transitions inherit that like any other. */ +/* Narrow mode: the contextual column leaves the flow and becomes an overlay + drawer that slides in from the left, so it never permanently steals width + from the main pane. The backdrop dims and dismisses on click. The trigger + is a small floating button pinned to the main pane's top-left corner, + rendered only when the shell is in "narrow" layout (gated in JSX). */ +.shell-drawer-trigger { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 20; + display: grid; + height: 2rem; + width: 2rem; + flex-shrink: 0; + place-items: center; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--card); + color: var(--foreground); + cursor: pointer; + transition: background 150ms ease; +} + +.shell-drawer-trigger:hover { + background: var(--muted); +} + +.shell-drawer-trigger svg { + height: 1rem; + width: 1rem; +} + +/* When the drawer trigger is present, the main content needs top padding so + it doesn't collide with the button. */ +.shell-main:has(.shell-drawer-trigger) > .shell-main-content { + padding-top: 2.75rem; +} + +.shell-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 30; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; +} + +.shell-drawer-backdrop[data-open="true"] { + opacity: 1; + pointer-events: auto; +} + +.shell-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 40; + width: min(18rem, 85vw); + transform: translateX(-100%); + transition: transform 200ms ease; + overflow: hidden; +} + +.shell-drawer[data-open="true"] { + transform: translateX(0); + box-shadow: var(--shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.2)); +} + +.shell-drawer .shell-contextual-panel { + width: 100%; + height: 100%; + border-right: none; +} + /* Column 1's footer docks: the bench switcher and identity row. The rail itself is `@corbits/react-ui`'s `SidebarRail` (showLabels), styled in its own stylesheet; only the footer parts composed into its `footer` slot diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index a6b91cf45..c3b489ac7 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -6,11 +6,17 @@ // actions. Deep links (`/c/:channelId`) open the canvas onto that channel. import { useEffect, useRef, useState, type ReactNode } from "react"; +import { PanelLeft } from "lucide-react"; import { channelIdFromPath, channelPath, isChannelPath } from "../channel-path"; import { useNavigate } from "../navigation"; import type { SessionUser } from "../session"; -import { canvasColumnAllowed, contextualPanelVisible } from "./breakpoints"; +import { + canvasColumnAllowed, + contextualPanelIsDrawer, + contextualPanelVisible, + railShowLabels, +} from "./breakpoints"; import { useShellFocusRescue } from "./focus-rescue"; import { useScrollReset } from "./use-scroll-reset"; import { @@ -46,6 +52,9 @@ export function AppShell({ ); const canvasAllowed = canvasColumnAllowed(layoutMode); const canvasOpen = resolveCanvasVisibility(canvasState, canvasAllowed); + const showContextualColumn = contextualPanelVisible(layoutMode); + const contextualAsDrawer = contextualPanelIsDrawer(layoutMode); + const [narrowPanelOpen, setNarrowPanelOpen] = useState(false); const frameRef = useRef(null); const mainRef = useRef(null); useShellFocusRescue(layoutMode, frameRef); @@ -69,14 +78,15 @@ export function AppShell({ return ( -
+
- {contextualPanelVisible(layoutMode) && ( + {showContextualColumn && ( )}
+ {contextualAsDrawer && ( + + )}
{children}
{canvasAllowed && ( @@ -95,6 +116,28 @@ export function AppShell({ onChannelChange={handleChannelChange} /> )} + {contextualAsDrawer && ( + <> +
setNarrowPanelOpen(false)} + /> +
+ setCanvasState(toggleCanvasColumn)} + canvasAllowed={canvasAllowed} + /> +
+ + )}
); diff --git a/apps/web/src/shell/breakpoints.test.ts b/apps/web/src/shell/breakpoints.test.ts new file mode 100644 index 000000000..00200a760 --- /dev/null +++ b/apps/web/src/shell/breakpoints.test.ts @@ -0,0 +1,60 @@ +// Tests the layout-mode predicates the shell renders from. Each function is +// pure — no DOM, no matchMedia — so the full decision tree is covered by a +// table over the three modes. + +import { describe, expect, test } from "bun:test"; + +import { + canvasColumnAllowed, + contextualPanelIsDrawer, + contextualPanelVisible, + railShowLabels, + shellLayoutModeForWidth, + type ShellLayoutMode, +} from "./breakpoints"; + +const MODES: readonly ShellLayoutMode[] = ["expanded", "compact", "narrow"]; + +describe("breakpoints", () => { + test("canvas column is allowed only in expanded mode", () => { + expect(canvasColumnAllowed("expanded")).toBe(true); + expect(canvasColumnAllowed("compact")).toBe(false); + expect(canvasColumnAllowed("narrow")).toBe(false); + }); + + test("contextual panel is an inline column in expanded and compact", () => { + expect(contextualPanelVisible("expanded")).toBe(true); + expect(contextualPanelVisible("compact")).toBe(true); + expect(contextualPanelVisible("narrow")).toBe(false); + }); + + test("contextual panel becomes a drawer overlay only in narrow mode", () => { + expect(contextualPanelIsDrawer("expanded")).toBe(false); + expect(contextualPanelIsDrawer("compact")).toBe(false); + expect(contextualPanelIsDrawer("narrow")).toBe(true); + }); + + test("rail drops labels only in narrow mode", () => { + expect(railShowLabels("expanded")).toBe(true); + expect(railShowLabels("compact")).toBe(true); + expect(railShowLabels("narrow")).toBe(false); + }); + + test("every mode either shows the column inline or as a drawer — never both, never neither", () => { + for (const mode of MODES) { + const inline = contextualPanelVisible(mode); + const drawer = contextualPanelIsDrawer(mode); + expect(inline === !drawer).toBe(true); + } + }); + + test("width boundaries map to the expected modes", () => { + // 1280px laptop — expanded (canvas available, inline panel, labels on). + expect(shellLayoutModeForWidth(1280)).toBe("expanded"); + // 1024px laptop — compact (no canvas, inline panel, labels on). + expect(shellLayoutModeForWidth(1024)).toBe("compact"); + // 600px phone — narrow (no canvas, panel as drawer, labels off). + // The narrow boundary is strictly < 700, so 699 and below is narrow. + expect(shellLayoutModeForWidth(600)).toBe("narrow"); + }); +}); diff --git a/apps/web/src/shell/breakpoints.ts b/apps/web/src/shell/breakpoints.ts index d36750932..cce80428f 100644 --- a/apps/web/src/shell/breakpoints.ts +++ b/apps/web/src/shell/breakpoints.ts @@ -41,3 +41,24 @@ export function contextualPanelVisible(mode: ShellLayoutMode): boolean { export function canvasColumnAllowed(mode: ShellLayoutMode): boolean { return mode === "expanded"; } + +/** The rail shows captions under its icons on every mode except "narrow", + * where width is at a premium. Icon-only still surfaces every destination — + * the labels collapse, not the items — so nothing is lost. */ +export function railShowLabels(mode: ShellLayoutMode): boolean { + return mode !== "narrow"; +} + +/** Below "narrow" the contextual panel leaves the flow entirely and becomes + * an overlay drawer, so it never permanently steals width from the main + * pane on a phone-width viewport. The drawer is always reachable via its + * trigger; the panel content is unchanged. */ +export function contextualPanelIsDrawer(mode: ShellLayoutMode): boolean { + return mode === "narrow"; +} + +// Smoke checklist — the three widths the responsive layout is verified +// against. All three must show zero horizontal scroll: +// 1280px (expanded) — rail + contextual column + canvas available +// 1024px (compact) — rail + contextual column, canvas hidden +// 700px (narrow) — rail icon-only + contextual as drawer overlay diff --git a/apps/web/src/shell/rail.tsx b/apps/web/src/shell/rail.tsx index b568552b0..fbf1d30db 100644 --- a/apps/web/src/shell/rail.tsx +++ b/apps/web/src/shell/rail.tsx @@ -19,11 +19,13 @@ export function Rail({ onNavigate, user, onSignOut, + showLabels = true, }: { readonly path: string; readonly onNavigate: (to: string) => void; readonly user: SessionUser; readonly onSignOut: () => void; + readonly showLabels?: boolean; }) { // `SidebarRail` flags the item whose id equals `activeId`; the nav routes // own prefix matching (e.g. /routines/:id lights Routines), so the active @@ -35,7 +37,7 @@ export function Rail({ return ( ({ id: route.path,