From 601e1161497b167f09f5a2cd73ba4681592cb77e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:55:12 -0700 Subject: [PATCH] fix(web): the shell works at phone width with an overlay sidebar and full-width drawer (CL-9590) --- apps/web/src/bench/bench-drawer.tsx | 1 + apps/web/src/bench/drawer-narrow.css | 20 ++++++++++ apps/web/src/lib/icons/index.tsx | 1 + apps/web/src/shell/app-shell.tsx | 33 +++++++++++++++- apps/web/src/shell/shell-narrow.css | 59 ++++++++++++++++++++++++++++ 5 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/bench/drawer-narrow.css create mode 100644 apps/web/src/shell/shell-narrow.css diff --git a/apps/web/src/bench/bench-drawer.tsx b/apps/web/src/bench/bench-drawer.tsx index 6356963f50..f4874d1079 100644 --- a/apps/web/src/bench/bench-drawer.tsx +++ b/apps/web/src/bench/bench-drawer.tsx @@ -1,4 +1,5 @@ import { Button } from "@corbits/react-ui"; +import "./drawer-narrow.css"; import { X } from "@/lib/icons"; import { useEffect, useRef, useState, type ReactNode } from "react"; diff --git a/apps/web/src/bench/drawer-narrow.css b/apps/web/src/bench/drawer-narrow.css new file mode 100644 index 0000000000..e66d0a6454 --- /dev/null +++ b/apps/web/src/bench/drawer-narrow.css @@ -0,0 +1,20 @@ +/* Phone width: the drawer is a full-width overlay above its scrim. */ +@media (max-width: 860px) { + .wb .drawer { + inset: 0; + width: auto; + border-radius: 0; + transform: translateX(100%); + opacity: 1; + transition: transform 300ms var(--ease-drawer); + } + .wb[data-drawer="open"] .drawer { + transform: none; + } + .wb:not([data-drawer="open"]) .drawer { + visibility: hidden; + transition: + transform 300ms var(--ease-drawer), + visibility 0s 300ms; + } +} diff --git a/apps/web/src/lib/icons/index.tsx b/apps/web/src/lib/icons/index.tsx index 4ccef91c10..f6a480075c 100644 --- a/apps/web/src/lib/icons/index.tsx +++ b/apps/web/src/lib/icons/index.tsx @@ -15,6 +15,7 @@ export { ArrowUp, ArrowUpDown as ArrowsDownUp, Bell, + PanelLeft, BookMarked as BookBookmark, Bot as Robot, Captions, diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 5f0aabe44f..769acddf5f 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -1,7 +1,9 @@ // Canvas state is NOT owned here — see `shell-chrome-provider.tsx` for why // it has to be visible to the command palette too. -import { lazy, Suspense, useRef, useState, type ReactNode, type RefObject } from "react"; +import { lazy, Suspense, useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; +import { Button } from "@corbits/react-ui"; +import { PanelLeft } from "@/lib/icons"; import type { ArtifactSaveState } from "@/library"; import { WorkbenchLoadingState } from "@/chat"; @@ -26,6 +28,7 @@ import { import { Sidebar } from "./sidebar"; import { ShellContextMenu } from "./context-menu/shell-context-menu"; import { FirstRunTour } from "./first-run-tour"; +import "./shell-narrow.css"; const CanvasColumn = lazy(async () => ({ default: (await import("./canvas-column")).CanvasColumn, @@ -120,6 +123,17 @@ export function AppShell({ const closeCanvas = useCloseCanvas(); const toggleCanvasFocus = useToggleCanvasFocus(); const mainRef = useRef(null); + // Keyed by path so any navigation closes the phone-width sidebar. + const [sidebarOpenFor, setSidebarOpenFor] = useState(null); + const sidebarOpen = sidebarOpenFor === path; + useEffect(() => { + if (!sidebarOpen) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setSidebarOpenFor(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [sidebarOpen]); const pendingCount = usePendingApprovalCount(tenantId); const pendingChip = pendingCount === null @@ -132,7 +146,22 @@ export function AppShell({ : { tone: "ok" as const, label: "All caught up" }; return ( -
+
+ +
setSidebarOpenFor(null)} + aria-hidden="true" + />
diff --git a/apps/web/src/shell/shell-narrow.css b/apps/web/src/shell/shell-narrow.css new file mode 100644 index 0000000000..5d72f5d7ce --- /dev/null +++ b/apps/web/src/shell/shell-narrow.css @@ -0,0 +1,59 @@ +/* Phone width: the sidebar is an off-canvas overlay, opened by the toggle. */ +.shell-sidebar-toggle, +.shell-sidebar-scrim { + display: none; +} + +@media (max-width: 860px) { + .shell-sidebar-toggle { + display: inline-flex; + position: fixed; + top: 0.5rem; + left: 0.5rem; + z-index: 30; + } + .shell-frame .stage-top-bar { + padding-left: 3rem; + } + .shell-frame .shell-sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 45; + width: min(17.5rem, 85vw); + min-width: 0; + box-shadow: var(--overlay); + transition: + transform 300ms var(--ease-drawer), + visibility 0s 300ms; + transform: translateX(-100%); + visibility: hidden; + } + .shell-frame[data-sidebar-open="true"] .shell-sidebar { + transform: none; + visibility: visible; + transition: transform 300ms var(--ease-drawer); + } + .shell-frame[data-sidebar-open="true"] .shell-sidebar-toggle { + visibility: hidden; + } + .shell-sidebar-scrim { + display: block; + position: fixed; + inset: 0; + z-index: 44; + background: rgb(0 0 0 / 24%); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease-out; + } + .shell-frame[data-sidebar-open="true"] .shell-sidebar-scrim { + opacity: 1; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .shell-frame .shell-sidebar { + transition: none; + } +}