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:
co-authored by
Claude Fable 5
parent
b135c114df
commit
2df933b0c0
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user