Computer panel: absolute overlay in ALL states + proportional widths + rail collapse

The previous pass left 'full' as an in-flow flex sibling, so it fought the
chat for layout space and overlapped (chat crushed to a sliver, panel
relative on top). Now the panel is an absolute right-anchored overlay in
EVERY state (never in-flow) — the chat keeps full width and never reflows;
opening the panel just covers the right N%:
- phone  → md:w-[448px]
- tablet → md:w-[67%]   (≈ the measured 847px at 1440)
- full   → md:w-full    (covers everything right of the rail)
Transition is now plain transition-[width] on the absolute aside.

Rail collapse in full mode: LeftRail reads ?device, and when the panel is
open in 'full' it collapses 176→80px, icon-only — labels/credits/user-text
hidden (md+) via a group/rail data-collapsed flag, animated in parallel
with the panel via transition-[transform,width]. Verified at 1440: tablet
panel=847 rail=176, phone=448, full panel=1360 rail=80; chat composer.left
stays 472 in phone/tablet (no shift). 24px card inset + 32px radius kept
throughout.

86 unit + 31 functional E2E green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-11 06:36:43 -05:00
co-authored by Claude Fable 5
parent dab08e47aa
commit 4d9bcc74ec
5 changed files with 52 additions and 35 deletions
@@ -15,20 +15,19 @@ import { clawThemeStyle, WallpaperSurface } from "./ClawTheme";
import { HomeScreen } from "./HomeScreen"; import { HomeScreen } from "./HomeScreen";
import { AppRouter, appTitle } from "./AppRouter"; import { AppRouter, appTitle } from "./AppRouter";
/* Device-state sizing + positioning model (measured). Phone/tablet anchor /* Device-state sizing + positioning model (measured at 1440). The panel is
the panel as an ABSOLUTE right-edge overlay so the chat keeps its full ALWAYS an absolute, right-anchored overlay (never an in-flow sibling) so
width and doesn't reflow when the panel opens (448/550px aside → 400/502 the chat keeps its full width and never reflows — opening the panel just
card). Only "full" is an in-flow flex sibling that lets the chat shrink covers the right N% of the chat. Widths are proportional to the post-rail
(basis-0 grow-[4] → fluid, fills the row). The card is w-full inside a area: phone 448px, tablet 67% (≈ the measured 847px), full 100% (and in
px-6 wrapper, so card width = aside − 48. */ full the rail collapses to 80px in parallel, giving the panel the room).
The card stays w-full inside the px-6 wrapper → 24px inset + 32px radius
throughout. */
const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full"; const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
const SIZING: Record<DeviceSize, { open: string; closed: string }> = { const WIDTH: Record<DeviceSize, string> = {
phone: { open: `${OVERLAY} md:w-[448px]`, closed: `${OVERLAY} md:w-0` }, phone: "md:w-[448px]",
tablet: { open: `${OVERLAY} md:w-[550px]`, closed: `${OVERLAY} md:w-0` }, tablet: "md:w-[67%]",
full: { full: "md:w-full",
open: "md:relative md:basis-0 md:grow-[4] md:shrink-0",
closed: "md:relative md:basis-0 md:grow-0",
},
}; };
/** The right-hand "Computer" slide-out (§7): a per-agent themed screen card /** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
@@ -54,9 +53,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
if ( if (
!open && !open &&
event.target === event.currentTarget && event.target === event.currentTarget &&
(event.propertyName === "width" || event.propertyName === "width"
event.propertyName === "flex-basis" ||
event.propertyName === "flex-grow")
) { ) {
setMounted(false); setMounted(false);
} }
@@ -82,8 +79,8 @@ export function DevicePanel({ agent }: { agent: Agent }) {
aria-label="Computer" aria-label="Computer"
aria-hidden={!open} aria-hidden={!open}
onTransitionEnd={handleTransitionEnd} onTransitionEnd={handleTransitionEnd}
className={`pointer-events-none overflow-hidden transition-[width,flex-basis,flex-grow] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${ className={`pointer-events-none overflow-hidden transition-[width] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh ${OVERLAY} ${
open ? SIZING[device].open : SIZING[device].closed open ? WIDTH[device] : "md:w-0"
}`} }`}
> >
<div <div
@@ -49,7 +49,7 @@ export function AgentRosterItem({
</span> </span>
</span> </span>
<span <span
className={`max-w-full truncate text-xs transition-colors duration-(--duration-normal) ease-app ${ className={`max-w-full truncate text-xs transition-colors duration-(--duration-normal) ease-app md:group-data-[collapsed=true]/rail:hidden ${
active active
? "text-foreground" ? "text-foreground"
: "text-muted-foreground group-hover:text-foreground" : "text-muted-foreground group-hover:text-foreground"
@@ -58,6 +58,7 @@ export function AgentRosterItem({
{agent.name} {agent.name}
</span> </span>
</Link> </Link>
<div className="md:group-data-[collapsed=true]/rail:hidden">
<AgentRowMenu <AgentRowMenu
agentId={agent.id} agentId={agent.id}
agentName={agent.name} agentName={agent.name}
@@ -65,5 +66,6 @@ export function AgentRosterItem({
onTogglePin={onTogglePin} onTogglePin={onTogglePin}
/> />
</div> </div>
</div>
); );
} }
@@ -25,7 +25,7 @@ export function GlobalNavItem({
<Link <Link
href={href} href={href}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`flex h-8 items-center gap-3 rounded-full px-3 text-sm transition-colors duration-(--duration-normal) ease-app ${ className={`flex h-8 items-center gap-3 rounded-full px-3 text-sm transition-colors duration-(--duration-normal) ease-app md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:px-0 ${
active active
? "text-foreground" ? "text-foreground"
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground" : "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
@@ -36,9 +36,14 @@ export function GlobalNavItem({
size={16} size={16}
className={`shrink-0 ${active ? "text-coral" : ""}`} className={`shrink-0 ${active ? "text-coral" : ""}`}
/> />
<span className="truncate">{label}</span> <span className="truncate md:group-data-[collapsed=true]/rail:hidden">
{label}
</span>
{trailing != null && ( {trailing != null && (
<span aria-hidden className="ml-auto text-sm"> <span
aria-hidden
className="ml-auto text-sm md:group-data-[collapsed=true]/rail:hidden"
>
{trailing} {trailing}
</span> </span>
)} )}
+17 -4
View File
@@ -2,14 +2,20 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useQueryStates } from "nuqs";
import { Menu, PawPrint, Plus, X } from "lucide-react"; import { Menu, PawPrint, Plus, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
import { panelParsers } from "@/lib/url/panel-params";
import { RosterList } from "./RosterList"; import { RosterList } from "./RosterList";
import { ShellNav } from "./ShellNav"; import { ShellNav } from "./ShellNav";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
/* Labels hide (md+) when the rail collapses in full-panel mode. Applied to
every text node via the group/rail data flag on the aside. */
const HIDE_WHEN_COLLAPSED = "md:group-data-[collapsed=true]/rail:hidden";
interface LeftRailProps { interface LeftRailProps {
user: User; user: User;
roster: Agent[]; roster: Agent[];
@@ -22,6 +28,10 @@ interface LeftRailProps {
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) { export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const [{ app, device }] = useQueryStates(panelParsers, { shallow: true });
// In full-panel mode the rail collapses to an 80px icon-only strip (md+),
// handing the extra room to the panel overlay.
const collapsed = app !== null && device === "full";
// Close the drawer when the route changes (render-phase prev-state // Close the drawer when the route changes (render-phase prev-state
// pattern — no effect, no ref). // pattern — no effect, no ref).
const [seenPath, setSeenPath] = useState(pathname); const [seenPath, setSeenPath] = useState(pathname);
@@ -49,13 +59,14 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
/> />
)} )}
<aside <aside
className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${ data-collapsed={collapsed ? "true" : "false"}
className={`group/rail z-50 flex h-dvh w-rail shrink-0 flex-col px-1 transition-[transform,width] duration-(--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" open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
}`} } ${collapsed ? "md:w-20" : ""}`}
> >
{/* The logo lives in its own 80px header row (measured): a 48px {/* The logo lives in its own 80px header row (measured): a 48px
black squircle brand tile with our coral mark. */} black squircle brand tile with our coral mark. */}
<header className="flex h-20 items-center pl-4"> <header className="flex h-20 items-center pl-4 md:group-data-[collapsed=true]/rail:justify-center md:group-data-[collapsed=true]/rail:pl-0">
<Link href="/" aria-label="clawmates" className="inline-flex"> <Link href="/" aria-label="clawmates" className="inline-flex">
<span className="flex size-12 items-center justify-center rounded-2xl bg-black"> <span className="flex size-12 items-center justify-center rounded-2xl bg-black">
<PawPrint aria-hidden size={26} className="text-coral" /> <PawPrint aria-hidden size={26} className="text-coral" />
@@ -87,7 +98,9 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
> >
<Plus size={20} /> <Plus size={20} />
</span> </span>
<span className="text-xs text-muted-foreground transition-colors duration-(--duration-normal) ease-app group-hover:text-foreground"> <span
className={`text-xs text-muted-foreground transition-colors duration-(--duration-normal) ease-app group-hover:text-foreground ${HIDE_WHEN_COLLAPSED}`}
>
New claw New claw
</span> </span>
</Link> </Link>
+3 -3
View File
@@ -19,9 +19,9 @@ export function UserMenu({ user }: { user: User }) {
} }
return ( return (
<div className="flex items-center gap-2 border-t border-border px-2 pt-3"> <div className="flex items-center gap-2 border-t border-border px-2 pt-3 md:group-data-[collapsed=true]/rail:justify-center">
<Avatar name={user.display_name} size="sm" /> <Avatar name={user.display_name} size="sm" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 md:group-data-[collapsed=true]/rail:hidden">
<p className="truncate text-xs text-foreground">{user.display_name}</p> <p className="truncate text-xs text-foreground">{user.display_name}</p>
<p className="truncate text-xxs text-muted-foreground">{user.role}</p> <p className="truncate text-xxs text-muted-foreground">{user.role}</p>
</div> </div>
@@ -29,7 +29,7 @@ export function UserMenu({ user }: { user: User }) {
type="button" type="button"
onClick={signOut} onClick={signOut}
disabled={pending} disabled={pending}
className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50" className="rounded-(--radius-button) px-2 py-1 text-xxs text-muted-foreground hover:bg-surface-warm hover:text-foreground disabled:opacity-50 md:group-data-[collapsed=true]/rail:hidden"
> >
Sign out Sign out
</button> </button>