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:
co-authored by
Claude Fable 5
parent
200bb56299
commit
51677fef2d
@@ -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">
|
||||||
|
|||||||
@@ -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,45 +17,80 @@ 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
|
||||||
clawmates
|
type="button"
|
||||||
</p>
|
aria-label="Open navigation"
|
||||||
<div className="flex-1 overflow-y-auto">
|
onClick={() => setOpen(true)}
|
||||||
{roster.length === 0 ? (
|
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"
|
||||||
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
|
>
|
||||||
) : (
|
<Menu aria-hidden size={18} />
|
||||||
<ul aria-label="Your claws" className="flex flex-col">
|
</button>
|
||||||
{roster.map((agent) => (
|
{open && (
|
||||||
<li key={agent.id}>
|
<button
|
||||||
<AgentRosterItem agent={agent} />
|
type="button"
|
||||||
</li>
|
aria-label="Close navigation"
|
||||||
))}
|
onClick={() => setOpen(false)}
|
||||||
</ul>
|
className="fixed inset-0 z-40 hidden bg-black/50 max-md:block"
|
||||||
)}
|
/>
|
||||||
<Link
|
)}
|
||||||
href="/claws/new"
|
<aside
|
||||||
className="group mt-1 flex w-full flex-col items-center gap-1 py-1.5"
|
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"
|
||||||
<span
|
}`}
|
||||||
aria-hidden
|
>
|
||||||
className="inline-flex size-12 items-center justify-center rounded-radius-squircle border border-dashed border-border text-coral transition-colors duration-normal ease-app group-hover:border-coral"
|
<div className="flex items-center justify-between px-3 pb-4">
|
||||||
|
<p className="font-mono text-sm font-semibold tracking-tight text-foreground">
|
||||||
|
clawmates
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close navigation"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="hidden text-muted-foreground max-md:inline-flex"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<X aria-hidden size={16} />
|
||||||
</span>
|
</button>
|
||||||
<span className="text-xs text-muted-foreground transition-colors duration-normal ease-app group-hover:text-foreground">
|
</div>
|
||||||
New claw
|
<div className="flex-1 overflow-y-auto">
|
||||||
</span>
|
{roster.length === 0 ? (
|
||||||
</Link>
|
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
|
||||||
</div>
|
) : (
|
||||||
<div className="flex flex-col gap-3 px-1 pt-3">
|
<RosterList roster={roster} />
|
||||||
<ShellNav creditsBalance={creditsBalance} />
|
)}
|
||||||
<UserMenu user={user} />
|
<Link
|
||||||
</div>
|
href="/claws/new"
|
||||||
</aside>
|
className="group mt-1 flex w-full flex-col items-center gap-1 py-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-flex size-12 items-center justify-center rounded-radius-squircle border border-dashed border-border text-coral transition-colors duration-normal ease-app group-hover:border-coral"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground transition-colors duration-normal ease-app group-hover:text-foreground">
|
||||||
|
New claw
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 px-1 pt-3">
|
||||||
|
<ShellNav creditsBalance={creditsBalance} />
|
||||||
|
<UserMenu user={user} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user