Computer panel: header toggles, device-state widths, bigger dock

Measured panel feedback — four fidelity gaps closed:

1. Size toggles + close moved OUT of the panel card header into the
   global chat header (where the reference mounts them), beside a thin
   divider. DeviceSizeToggle restyled to 28px p-1.5 rounded-md with
   inactive at opacity-30, active at full. The panel card header now
   carries only the home/back button + title + status dot.
2. Device-state widths wired (were stuck at 400px). DevicePanel now
   manages its own complementary <aside> sized by flex per ?device=:
   phone basis-[448px] (400 card), tablet basis-[550px] (502 card),
   full basis-0 grow-[4] — fluid, fills the row beside the chat. Verified
   live: aside measures 448 / 550 / 1011 at a 1440 viewport. Wrapper
   px-3→px-6 (24px gutters); card is now w-full (width driven by aside).
3. The grow animates — transition-[flex-basis,flex-grow] with the
   signature --duration-normal / ease-app on the aside.
4. Dock glyphs enlarged: tiles size-12→size-14 (56), glyph 22→34, plus
   the 11px label under each tile.

Mount/unmount preserved via transitionend (flex-basis|flex-grow), as is
role=complementary aria-label=Computer, the device-panel-theme testid,
and the mobile full-screen overlay. p3/p4 updated to find the toggle +
close at page scope (now in the header, outside the panel region).
83 unit + 31 functional E2E green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 22:03:26 -05:00
co-authored by Claude Fable 5
parent b135c114df
commit 2df933b0c0
6 changed files with 124 additions and 77 deletions
+23 -2
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { List, Monitor, SquarePen } from "lucide-react"; import { List, Monitor, SquarePen, X } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { DeviceSizeToggle } from "@/components/computer/DeviceSizeToggle";
import { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
/** Chat header (measured): 80px, transparent, identity left; sessions / /** Chat header (measured): 80px, transparent, identity left; sessions /
@@ -17,9 +18,10 @@ export function ChatHeader({
agent: Agent; agent: Agent;
onNewSession: () => void; onNewSession: () => void;
}) { }) {
const [{ sessions }, setParams] = useQueryStates(panelParsers, { const [{ sessions, app, device }, setParams] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
}); });
const panelOpen = app !== null;
return ( return (
<header className="flex h-20 items-center gap-3 pr-6 pl-5"> <header className="flex h-20 items-center gap-3 pr-6 pl-5">
<Avatar <Avatar
@@ -64,6 +66,25 @@ export function ChatHeader({
> >
<Monitor aria-hidden size={20} /> <Monitor aria-hidden size={20} />
</button> </button>
{/* When the Computer is open, its size toggles + close live here in
the global header (measured), beside a thin divider. */}
{panelOpen && (
<>
<span aria-hidden className="mx-1 h-5 w-px bg-border" />
<DeviceSizeToggle
value={device}
onChange={(size) => setParams({ device: size })}
/>
<button
type="button"
aria-label="Close computer"
onClick={() => setParams({ app: null })}
className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg hover:text-foreground"
>
<X aria-hidden size={18} />
</button>
</>
)}
</header> </header>
); );
} }
@@ -1,31 +1,60 @@
"use client"; "use client";
import { ChevronLeft, X } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { useRef, useState, type CSSProperties } from "react"; import {
useRef,
useState,
type CSSProperties,
type TransitionEvent,
} from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
import { panelParsers, type AppId } from "@/lib/url/panel-params"; import { panelParsers, type AppId, type DeviceSize } from "@/lib/url/panel-params";
import { SlidePanel } from "@/components/ui/SlidePanel";
import { clawThemeStyle, WallpaperSurface } from "./ClawTheme"; import { clawThemeStyle, WallpaperSurface } from "./ClawTheme";
import { DeviceSizeToggle } from "./DeviceSizeToggle";
import { HomeScreen } from "./HomeScreen"; import { HomeScreen } from "./HomeScreen";
import { AppRouter, appTitle } from "./AppRouter"; import { AppRouter, appTitle } from "./AppRouter";
const WIDTHS = { full: 720, tablet: 448, phone: 340 } as const; /* Device-state sizing (measured): phone = 448px aside / 400 card, tablet =
550 / 502, full = fluid (the panel drops its fixed width and fills the
row beside the chat). Driven by flex so "full" is responsive; the grow
animates with the signature easing. The card is w-full inside a px-6
wrapper, so card width = aside − 48. */
const SIZING: Record<DeviceSize, string> = {
phone: "basis-[448px] grow-0",
tablet: "basis-[550px] grow-0",
full: "basis-0 grow-[4]",
};
/** The right-hand "Computer" slide-out (§7): the 448px docked rail hosts a /** The right-hand "Computer" slide-out (§7): a per-agent themed screen card
* 400×652 r32 "screen" card with the wallpaper stack; apps zoom out of * hosting every sub-app behind ?app=, sized by ?device=. The size toggles
* their home tile. All state deep-links via ?app=/?device=. */ * and close live in the chat header; this owns the card + its width. */
export function DevicePanel({ agent }: { agent: Agent }) { export function DevicePanel({ agent }: { agent: Agent }) {
const [{ app, device }, setParams] = useQueryStates(panelParsers, { const [{ app, device }, setParams] = useQueryStates(panelParsers, {
shallow: true, shallow: true,
}); });
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState<CSSProperties>({}); const [zoom, setZoom] = useState<CSSProperties>({});
const [mounted, setMounted] = useState(app !== null);
const open = app !== null; const open = app !== null;
const onHome = app === "home" || app === null; const onHome = app === "home" || app === null;
// Opening mounts content immediately; closing keeps it mounted through the
// collapse transition (flex-basis when leaving phone/tablet, flex-grow when
// leaving full), then unmounts.
if (open && !mounted) {
setMounted(true);
}
function handleTransitionEnd(event: TransitionEvent<HTMLElement>) {
if (
!open &&
event.target === event.currentTarget &&
(event.propertyName === "flex-basis" || event.propertyName === "flex-grow")
) {
setMounted(false);
}
}
function openApp(next: AppId, tileRect?: DOMRect) { function openApp(next: AppId, tileRect?: DOMRect) {
// Capture the launch transform at click time (refs may not be read // Capture the launch transform at click time (refs may not be read
// during render): the tapped tile's center, relative to the screen // during render): the tapped tile's center, relative to the screen
@@ -41,63 +70,59 @@ export function DevicePanel({ agent }: { agent: Agent }) {
} }
return ( return (
<SlidePanel open={open} width={WIDTHS[device]} label="Computer" overlay> <aside
role="complementary"
aria-label="Computer"
aria-hidden={!open}
onTransitionEnd={handleTransitionEnd}
className={`shrink-0 overflow-hidden transition-[flex-basis,flex-grow] duration-(--duration-normal) ease-app max-md:fixed max-md:inset-0 max-md:z-40 max-md:h-dvh max-md:basis-auto max-md:grow ${
open ? SIZING[device] : "basis-0 grow-0"
}`}
>
<div <div
data-testid="device-panel-theme" data-testid="device-panel-theme"
className="flex h-full flex-col px-3 pt-[88px] pb-6 max-md:bg-background max-md:p-0" className="flex h-full flex-col px-6 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 max-md:max-w-none max-md:rounded-none max-md:shadow-none" className="relative flex h-full w-full flex-col overflow-hidden rounded-[32px] shadow-screen max-md:rounded-none max-md:shadow-none"
> >
<WallpaperSurface> {mounted && (
<header className="relative flex h-11 shrink-0 items-center gap-2 px-3"> <WallpaperSurface>
<button <header className="relative flex h-11 shrink-0 items-center gap-2 px-3">
type="button"
aria-label="Computer home"
onClick={() => openApp("home")}
className="flex items-center gap-1.5 text-xs font-medium text-white/90 transition-colors hover:text-white"
>
{onHome ? (
<span
aria-hidden
className="size-2 rounded-full bg-rose-500"
/>
) : (
<ChevronLeft aria-hidden size={16} />
)}
{onHome ? `${agent.name}'s Computer` : appTitle(app)}
</button>
<div className="ml-auto flex items-center gap-2">
<DeviceSizeToggle
value={device}
onChange={(size) => setParams({ device: size })}
/>
<button <button
type="button" type="button"
aria-label="Close computer" aria-label="Computer home"
onClick={() => setParams({ app: null })} onClick={() => openApp("home")}
className="inline-flex size-6 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white" className="flex items-center gap-1.5 text-xs font-medium text-white/90 transition-colors hover:text-white"
> >
<X aria-hidden size={15} /> {onHome ? (
<span
aria-hidden
className="size-2 rounded-full bg-rose-500"
/>
) : (
<ChevronLeft aria-hidden size={16} />
)}
{onHome ? `${agent.name}'s Computer` : appTitle(app)}
</button> </button>
</div> </header>
</header> {onHome ? (
{onHome ? ( <HomeScreen agentName={agent.name} onOpen={openApp} />
<HomeScreen agentName={agent.name} onOpen={openApp} /> ) : (
) : ( <div
<div key={app}
key={app} style={zoom}
style={zoom} className="app-shell-anim-launch relative flex-1 overflow-y-auto bg-background/85"
className="app-shell-anim-launch relative flex-1 overflow-y-auto bg-background/85" >
> <AppRouter app={app} agent={agent} />
<AppRouter app={app} agent={agent} /> </div>
</div> )}
)} </WallpaperSurface>
</WallpaperSurface> )}
</div> </div>
</div> </div>
</SlidePanel> </aside>
); );
} }
@@ -11,8 +11,9 @@ const SIZES: { id: DeviceSize; label: string; icon: LucideIcon }[] = [
{ id: "phone", label: "Phone", icon: Smartphone }, { id: "phone", label: "Phone", icon: Smartphone },
]; ];
/** The §7 size toggle (measured): three icon buttons (monitor/tablet/ /** The §7 size toggle (measured): three 28px icon buttons (monitor/tablet/
* smartphone); the accessible name stays the size label. */ * smartphone) in the global chat header — inactive at 30% opacity, the
* active state at full. The accessible name stays the size label. */
export function DeviceSizeToggle({ export function DeviceSizeToggle({
value, value,
onChange, onChange,
@@ -21,7 +22,7 @@ export function DeviceSizeToggle({
onChange: (size: DeviceSize) => void; onChange: (size: DeviceSize) => void;
}) { }) {
return ( return (
<div role="radiogroup" aria-label="Panel size" className="flex gap-0.5"> <div role="radiogroup" aria-label="Panel size" className="flex items-center gap-0.5">
{SIZES.map((size) => ( {SIZES.map((size) => (
<button <button
key={size.id} key={size.id}
@@ -30,13 +31,11 @@ export function DeviceSizeToggle({
aria-label={size.label} aria-label={size.label}
aria-checked={value === size.id} aria-checked={value === size.id}
onClick={() => onChange(size.id)} onClick={() => onChange(size.id)}
className={`inline-flex size-6 items-center justify-center rounded-full transition-colors duration-(--duration-normal) ease-app ${ className={`inline-flex size-7 items-center justify-center rounded-md p-1.5 text-foreground transition-opacity duration-(--duration-normal) ease-app ${
value === size.id value === size.id ? "opacity-100" : "opacity-30 hover:opacity-100"
? "bg-white/15 text-white"
: "text-white/60 hover:text-white"
}`} }`}
> >
<size.icon aria-hidden size={13} /> <size.icon aria-hidden size={15} />
</button> </button>
))} ))}
</div> </div>
+13 -11
View File
@@ -88,17 +88,19 @@ export function HomeScreen({ agentName, onOpen }: HomeScreenProps) {
className="mx-auto flex gap-5 rounded-3xl bg-white/[0.07] px-6 pt-4 pb-3 shadow-dock-capsule ring-1 ring-white/[0.04] ring-inset backdrop-blur-[40px] backdrop-saturate-150" className="mx-auto flex gap-5 rounded-3xl bg-white/[0.07] px-6 pt-4 pb-3 shadow-dock-capsule ring-1 ring-white/[0.04] ring-inset backdrop-blur-[40px] backdrop-saturate-150"
> >
{HOME_DOCK.map((tile) => ( {HOME_DOCK.map((tile) => (
<button <div key={tile.app} className="flex flex-col items-center gap-1.5">
key={tile.app} <button
type="button" type="button"
aria-label={tile.label} aria-label={tile.label}
onClick={(e) => onClick={(e) =>
onOpen(tile.app, e.currentTarget.getBoundingClientRect()) onOpen(tile.app, e.currentTarget.getBoundingClientRect())
} }
className="flex size-12 items-center justify-center rounded-2xl bg-black shadow-dock-tile transition-transform duration-[0.1s] ease-out-app active:scale-[0.92]" className="flex size-14 items-center justify-center rounded-2xl bg-black shadow-dock-tile transition-transform duration-[0.1s] ease-out-app active:scale-[0.92]"
> >
<GradientGlyph icon={APP_ICON[tile.app]} size={22} /> <GradientGlyph icon={APP_ICON[tile.app]} size={34} />
</button> </button>
<span className="text-[11px] text-white/80">{tile.label}</span>
</div>
))} ))}
</div> </div>
</div> </div>
+1 -1
View File
@@ -43,7 +43,7 @@ test("the computer opens to the themed home screen with grid and dock", async ({
await expect(themed).toHaveAttribute("style", /--agent-accent/); await expect(themed).toHaveAttribute("style", /--agent-accent/);
// Size toggle flips ?device=. // Size toggle flips ?device=.
await panel.getByRole("radio", { name: "Phone" }).click(); await page.getByRole("radio", { name: "Phone" }).click();
await expect(page).toHaveURL(/device=phone/); await expect(page).toHaveURL(/device=phone/);
}); });
+1 -1
View File
@@ -34,7 +34,7 @@ test("connect Slack, then an outbound post is gated and broker-executed", async
await panel.getByLabel(/Signing secret/).fill("e2e-signing-secret"); await panel.getByLabel(/Signing secret/).fill("e2e-signing-secret");
await panel.getByRole("button", { name: "Connect Slack" }).click(); await panel.getByRole("button", { name: "Connect Slack" }).click();
await expect(panel.getByText("Slack is connected")).toBeVisible(); await expect(panel.getByText("Slack is connected")).toBeVisible();
await panel.getByRole("button", { name: "Close computer" }).click(); await page.getByRole("button", { name: "Close computer" }).click();
// Ask for a post: blocked behind the approval card. // Ask for a post: blocked behind the approval card.
const box = page.getByLabel("Message Scout"); const box = page.getByLabel("Message Scout");