R6: responsive — mobile rail drawer + full-screen Computer overlay

- SlidePanel gains an  prop: at ≤md the open Computer panel
  becomes a fixed full-screen layer (max-md:!w-full beats the inline
  width) instead of a cramped docked rail — the measured mobile/tablet
  behavior. The screen card goes full-bleed (no radius/shadow/max-width)
  on small screens; desktop docking is unchanged.
- LeftRail collapses behind a hamburger at ≤md and slides in as a drawer
  over the content with a backdrop, auto-closing on navigation
  (render-phase route check, no effect). Desktop keeps the static 176px
  rail (the hamburger and drawer transforms are max-md-scoped, so the
  1440 desktop suite is untouched).

83 unit tests; desktop p0/p3/a11y journeys green; verified at 390px the
rail collapses to the toggle.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 20:18:31 -05:00
co-authored by Claude Fable 5
parent 200bb56299
commit 51677fef2d
3 changed files with 102 additions and 45 deletions
@@ -41,15 +41,15 @@ export function DevicePanel({ agent }: { agent: Agent }) {
} }
return ( return (
<SlidePanel open={open} width={WIDTHS[device]} label="Computer"> <SlidePanel open={open} width={WIDTHS[device]} label="Computer" overlay>
<div <div
data-testid="device-panel-theme" data-testid="device-panel-theme"
className="flex h-full flex-col px-3 pt-[88px] pb-6" className="flex h-full flex-col px-3 pt-[88px] pb-6 max-md:bg-background max-md:p-0"
style={clawThemeStyle(agent)} style={clawThemeStyle(agent)}
> >
<div <div
ref={cardRef} ref={cardRef}
className="relative mx-auto flex h-full w-full max-w-[400px] flex-col overflow-hidden rounded-[32px] shadow-screen" className="relative mx-auto flex h-full w-full max-w-[400px] flex-col overflow-hidden rounded-[32px] shadow-screen max-md:max-w-none max-md:rounded-none max-md:shadow-none"
> >
<WallpaperSurface> <WallpaperSurface>
<header className="relative flex h-11 shrink-0 items-center gap-2 px-3"> <header className="relative flex h-11 shrink-0 items-center gap-2 px-3">
+52 -13
View File
@@ -1,8 +1,12 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react"; import { usePathname } from "next/navigation";
import { Menu, Plus, X } from "lucide-react";
import { useRef, useState } from "react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem"; import { RosterList } from "./RosterList";
import { ShellNav } from "./ShellNav"; import { ShellNav } from "./ShellNav";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
@@ -13,25 +17,59 @@ interface LeftRailProps {
} }
/** The persistent 176px left rail (measured): wordmark, the claw avatar /** The persistent 176px left rail (measured): wordmark, the claw avatar
* stack (48px squircles), dashed add tile, then global nav + the current * stack, dashed add tile, global nav + user. At ≤md it collapses behind
* user pinned to the bottom. */ * a hamburger and slides in as a drawer over the content. */
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) { export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
// Close the drawer when the route changes (render-phase, no effect).
const lastPath = useRef(pathname);
if (lastPath.current !== pathname) {
lastPath.current = pathname;
if (open) setOpen(false);
}
return ( return (
<aside className="flex h-dvh w-rail shrink-0 flex-col px-1 py-4"> <>
<p className="px-3 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground"> <button
type="button"
aria-label="Open navigation"
onClick={() => setOpen(true)}
className="fixed top-4 left-3 z-30 hidden size-9 items-center justify-center rounded-radius-button bg-surface-warm text-foreground shadow-card max-md:inline-flex"
>
<Menu aria-hidden size={18} />
</button>
{open && (
<button
type="button"
aria-label="Close navigation"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 hidden bg-black/50 max-md:block"
/>
)}
<aside
className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 py-4 transition-transform duration-normal ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`}
>
<div className="flex items-center justify-between px-3 pb-4">
<p className="font-mono text-sm font-semibold tracking-tight text-foreground">
clawmates clawmates
</p> </p>
<button
type="button"
aria-label="Close navigation"
onClick={() => setOpen(false)}
className="hidden text-muted-foreground max-md:inline-flex"
>
<X aria-hidden size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{roster.length === 0 ? ( {roster.length === 0 ? (
<p className="px-3 text-xs text-muted-foreground">No claws yet</p> <p className="px-3 text-xs text-muted-foreground">No claws yet</p>
) : ( ) : (
<ul aria-label="Your claws" className="flex flex-col"> <RosterList roster={roster} />
{roster.map((agent) => (
<li key={agent.id}>
<AgentRosterItem agent={agent} />
</li>
))}
</ul>
)} )}
<Link <Link
href="/claws/new" href="/claws/new"
@@ -53,5 +91,6 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
<UserMenu user={user} /> <UserMenu user={user} />
</div> </div>
</aside> </aside>
</>
); );
} }
+21 -3
View File
@@ -9,6 +9,9 @@ interface SlidePanelProps {
/** Accessible name for the complementary region. */ /** Accessible name for the complementary region. */
label: string; label: string;
className?: string; className?: string;
/** On ≤md screens, cover the viewport instead of docking as a rail
* (the measured responsive behavior for the Computer panel). */
overlay?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -18,12 +21,17 @@ interface SlidePanelProps {
* pushes layout instead of overlaying it, while the inner div keeps the final * pushes layout instead of overlaying it, while the inner div keeps the final
* width so children never reflow mid-animation. Content stays mounted during * width so children never reflow mid-animation. Content stays mounted during
* the exit transition and unmounts on `transitionend`. * the exit transition and unmounts on `transitionend`.
*
* With `overlay`, at ≤md the open panel becomes a fixed full-screen layer
* (`!w-full` beats the inline width) so phones/tablets get the spec's
* full-screen Computer instead of a cramped rail.
*/ */
export function SlidePanel({ export function SlidePanel({
open, open,
width, width,
label, label,
className, className,
overlay = false,
children, children,
}: SlidePanelProps) { }: SlidePanelProps) {
const [mounted, setMounted] = useState(open); const [mounted, setMounted] = useState(open);
@@ -35,21 +43,31 @@ export function SlidePanel({
} }
function handleTransitionEnd(event: TransitionEvent<HTMLDivElement>) { function handleTransitionEnd(event: TransitionEvent<HTMLDivElement>) {
if (event.propertyName === "width" && !open && event.target === event.currentTarget) { if (
event.propertyName === "width" &&
!open &&
event.target === event.currentTarget
) {
setMounted(false); setMounted(false);
} }
} }
const overlayTrack =
overlay && open
? " max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh max-md:!w-full"
: "";
const overlayInner = overlay ? " max-md:!w-full" : "";
return ( return (
<div <div
role="complementary" role="complementary"
aria-label={label} aria-label={label}
aria-hidden={!open} aria-hidden={!open}
className={`panel-track ${className ?? ""}`} className={`panel-track${overlayTrack} ${className ?? ""}`}
style={{ width: open ? `${width}px` : "0px" }} style={{ width: open ? `${width}px` : "0px" }}
onTransitionEnd={handleTransitionEnd} onTransitionEnd={handleTransitionEnd}
> >
<div style={{ width: `${width}px` }} className="h-full"> <div style={{ width: `${width}px` }} className={`h-full${overlayInner}`}>
{mounted ? children : null} {mounted ? children : null}
</div> </div>
</div> </div>