Panel app-header system + Settings hero (P1, capture-verified)

The panel app header now matches WorkClaw's measured pattern (dark captures
agent-settings / files-explorer / edit-profile):

- App TOP screen: the header lives on the app surface (sticky), showing the
  app name (text-lg font-semibold) + a size-7 rounded-full close-X → home,
  at px-5 pt-4 pb-3 — replacing the old px-8 pt-6 chevron+small-title. The
  home screen keeps its dot + "{agent}'s Computer" header.
- Sub-screens (AppShell SubViewHeader): back-chevron PILL + text-lg
  font-semibold title at px-5 pt-4 pb-3 (was text-xs px-3 pt-3 pb-2).
- Settings hero: 96px squircle avatar + text-xl name + text-sm role (matches
  the capture; reviewer's 126px was wrong). New Avatar `settings` size.

p3-panel E2E updated (home nav is now the "Close" button). typecheck/lint/
86 unit/31 E2E/6 visual all green; no visual baseline change.

Known follow-up (P2): Settings/Files sub-screens still stack the top header +
the back-header (pre-existing) — to be unified per-app.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-12 21:32:02 -05:00
co-authored by Claude Opus 4.8
parent db3dcff932
commit b61073ce05
5 changed files with 45 additions and 33 deletions
@@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronLeft } from "lucide-react"; import { X } from "lucide-react";
import { useQueryStates } from "nuqs"; import { useQueryStates } from "nuqs";
import { import {
useRef, useRef,
@@ -92,32 +92,41 @@ export function DevicePanel({ agent }: { agent: Agent }) {
> >
{mounted && ( {mounted && (
<WallpaperSurface> <WallpaperSurface>
<header className="relative flex shrink-0 items-center gap-2 px-8 pt-6">
<button
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 ? ( {onHome ? (
<>
<header className="relative flex shrink-0 items-center gap-2 px-8 pt-6">
<span className="flex items-center gap-1.5 text-xs font-medium text-white/90">
<span <span
aria-hidden aria-hidden
className="size-2 rounded-full bg-rose-500" className="size-2 rounded-full bg-rose-500"
/> />
) : ( {`${agent.name}'s Computer`}
<ChevronLeft aria-hidden size={16} /> </span>
)}
{onHome ? `${agent.name}'s Computer` : appTitle(app)}
</button>
</header> </header>
{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"
> >
{/* Top app header lives on the app surface (sticky), like the
reference: app name + close-X (→ home). Sub-screens render
their own back-header via AppShell. */}
<header className="sticky top-0 z-10 flex shrink-0 items-center gap-2 bg-background/85 px-5 pt-4 pb-3 backdrop-blur-md">
<span className="flex-1 truncate text-lg font-semibold">
{appTitle(app)}
</span>
<button
type="button"
aria-label="Close"
onClick={() => openApp("home")}
className="flex size-7 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800"
>
<X aria-hidden size={16} />
</button>
</header>
<AppRouter app={app} agent={agent} /> <AppRouter app={app} agent={agent} />
</div> </div>
)} )}
@@ -19,17 +19,18 @@ export function SubViewHeader({
action?: ReactNode; action?: ReactNode;
}) { }) {
return ( return (
<div className="flex items-center gap-2 px-3 pt-3 pb-2"> <header className="flex items-center gap-2 px-5 pt-4 pb-3">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground" className="-my-1 -ml-2 flex min-w-0 items-center gap-1.5 rounded-full py-1.5 pl-2 pr-3 text-left transition-colors hover:bg-neutral-800"
> >
<ChevronLeft aria-hidden size={14} /> <ChevronLeft aria-hidden size={18} className="shrink-0" />
{title} <span className="truncate text-lg font-semibold">{title}</span>
</button> </button>
{action != null && <span className="ml-auto">{action}</span>} <div className="flex-1" />
</div> {action != null && <span>{action}</span>}
</header>
); );
} }
@@ -150,10 +150,10 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
const agentsMode = settings.data?.access_policy.agents.mode ?? "any"; const agentsMode = settings.data?.access_policy.agents.mode ?? "any";
return ( return (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex flex-col items-center gap-1 py-2"> <div className="flex flex-col items-center px-6 pt-7 pb-5">
<Avatar name={live.name} accent={live.accent} size="lg" shape="squircle" /> <Avatar name={live.name} accent={live.accent} size="settings" shape="squircle" />
<p className="text-sm font-medium">{live.name}</p> <h1 className="mt-3 text-xl font-semibold tracking-tight">{live.name}</h1>
<p className="text-xs text-muted-foreground">{live.job_title}</p> <p className="mt-0.5 text-sm text-muted-foreground">{live.job_title}</p>
</div> </div>
<div className="divide-y divide-divider-subtle overflow-hidden rounded-xl bg-surface-warm text-xs"> <div className="divide-y divide-divider-subtle overflow-hidden rounded-xl bg-surface-warm text-xs">
<div className="flex justify-between px-3 py-2.5"> <div className="flex justify-between px-3 py-2.5">
+3 -1
View File
@@ -2,7 +2,7 @@ interface AvatarProps {
name: string; name: string;
/** Per-agent accent hex; defaults to the brand coral. */ /** Per-agent accent hex; defaults to the brand coral. */
accent?: string; accent?: string;
size?: "sm" | "md" | "lg" | "rail" | "chat" | "header" | "welcome"; size?: "sm" | "md" | "lg" | "rail" | "chat" | "header" | "settings" | "welcome";
/** Circle (people) or 17px-radius squircle (claws, measured). */ /** Circle (people) or 17px-radius squircle (claws, measured). */
shape?: "circle" | "squircle"; shape?: "circle" | "squircle";
/** Green presence dot overlay (rail + chat headers). */ /** Green presence dot overlay (rail + chat headers). */
@@ -16,6 +16,7 @@ const SIZES = {
rail: "size-[58px] text-xl" /* 58px claw rail tile */, rail: "size-[58px] text-xl" /* 58px claw rail tile */,
chat: "size-[50px] text-base" /* assistant message avatar */, chat: "size-[50px] text-base" /* assistant message avatar */,
header: "size-[58px] text-2xl" /* chat-header identity */, header: "size-[58px] text-2xl" /* chat-header identity */,
settings: "size-24 text-3xl" /* 96px Settings hero (measured) */,
welcome: "size-[126px] text-4xl" /* empty-state hero */, welcome: "size-[126px] text-4xl" /* empty-state hero */,
}; };
@@ -24,6 +25,7 @@ const SIZES = {
const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = { const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = {
rail: "rounded-[20px]", rail: "rounded-[20px]",
header: "rounded-[20px]", header: "rounded-[20px]",
settings: "rounded-[34px]",
welcome: "rounded-[40px]", welcome: "rounded-[40px]",
}; };
+2 -2
View File
@@ -121,13 +121,13 @@ test("every panel app is reachable and the deep link cold-loads", async ({
["Skills", /Scout's Skills|Installed skills/], ["Skills", /Scout's Skills|Installed skills/],
]; ];
for (const [tile, marker] of stops) { for (const [tile, marker] of stops) {
await panel.getByRole("button", { name: "Computer home" }).click(); await panel.getByRole("button", { name: "Close" }).click();
await panel.getByRole("button", { name: tile, exact: true }).click(); await panel.getByRole("button", { name: tile, exact: true }).click();
await expect(panel.getByText(marker).first()).toBeVisible(); await expect(panel.getByText(marker).first()).toBeVisible();
} }
// Inline keys connect from the directory (P4): Notion via API key. // Inline keys connect from the directory (P4): Notion via API key.
await panel.getByRole("button", { name: "Computer home" }).click(); await panel.getByRole("button", { name: "Close" }).click();
await panel.getByRole("button", { name: "Add Apps", exact: true }).click(); await panel.getByRole("button", { name: "Add Apps", exact: true }).click();
await panel.getByRole("button", { name: "Connect Notion" }).click(); await panel.getByRole("button", { name: "Connect Notion" }).click();
await panel.getByLabel("Notion API key").fill("secret_notion_key"); await panel.getByLabel("Notion API key").fill("secret_notion_key");