Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 46 additions & 3 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const mainRef = useRef<HTMLDivElement>(null);
useShellFocusRescue(layoutMode, frameRef);
Expand All @@ -69,14 +78,15 @@ export function AppShell({

return (
<CanvasAvailabilityProvider allowed={canvasAllowed}>
<div className="shell-frame" ref={frameRef}>
<div className="shell-frame" ref={frameRef} data-layout={layoutMode}>
<Rail
path={path}
onNavigate={navigate}
user={user}
onSignOut={onSignOut}
showLabels={railShowLabels(layoutMode)}
/>
{contextualPanelVisible(layoutMode) && (
{showContextualColumn && (
<ContextualPanel
path={path}
onNavigate={navigate}
Expand All @@ -86,6 +96,17 @@ export function AppShell({
/>
)}
<div className="shell-main" ref={mainRef}>
{contextualAsDrawer && (
<button
type="button"
className="shell-drawer-trigger"
aria-label="Open panel"
aria-expanded={narrowPanelOpen}
onClick={() => setNarrowPanelOpen(true)}
>
<PanelLeft />
</button>
)}
<div className="shell-main-content">{children}</div>
</div>
{canvasAllowed && (
Expand All @@ -95,6 +116,28 @@ export function AppShell({
onChannelChange={handleChannelChange}
/>
)}
{contextualAsDrawer && (
<>
<div
className="shell-drawer-backdrop"
data-open={narrowPanelOpen}
onClick={() => setNarrowPanelOpen(false)}
/>
<div
className="shell-drawer"
data-open={narrowPanelOpen}
inert={!narrowPanelOpen}
>
<ContextualPanel
path={path}
onNavigate={navigate}
canvasOpen={canvasState.open}
onToggleCanvas={() => setCanvasState(toggleCanvasColumn)}
canvasAllowed={canvasAllowed}
/>
</div>
</>
)}
</div>
</CanvasAvailabilityProvider>
);
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/shell/breakpoints.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
21 changes: 21 additions & 0 deletions apps/web/src/shell/breakpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion apps/web/src/shell/rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,7 +37,7 @@ export function Rail({
return (
<SidebarRail
label="Workbench"
showLabels
showLabels={showLabels}
activeId={activeRoute?.path ?? ""}
items={NAV_ROUTES.map((route: AppRoute) => ({
id: route.path,
Expand Down
Loading