R1+R2: shell and chat restyled to the reference system

Shell (main-chat-shell-spec): rail rebuilt around a 48px squircle claw
avatar stack with a 2px coral active ring + name beneath + online dot,
dashed 'New claw' tile, lucide-icon nav pills with the live credits
balance shown inline (coral when negative, aria-hidden so the nav link
name stays 'Credits'); 80px transparent chat header with 36px round
sessions/new-session icon buttons and the 42px coral-glow Computer
launcher; 768px centered content rail. RosterList computes the active
claw from the pathname so AgentRosterItem stays presentational.

Chat (chat-message-components): asymmetric layout — user pill #1A1A1A
radius 24/24/4 with inset white ring + dual shadow, capped 75%, vs the
bubble-less assistant message (50px squircle avatar, 15px gap, plain
14px/1.7 text); messageSlideIn entrance; 126px welcome avatar with the
24px/600/-0.6px heading (claw name in coral) and lucide-led suggestion
chips; floating 24px-radius neutral-800 composer card with the cream
Send pill; StepTrace rows restyled to the system.

Brand marks (Chrome, Slack) vendored to public/services from the bundle.
83 unit tests, full 29-journey functional E2E green.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 19:41:31 -05:00
co-authored by Claude Fable 5
parent 3ec1d3b829
commit 6e061515b8
17 changed files with 361 additions and 128 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" height="48" width="48"><defs><linearGradient id="a" x1="3.2173" y1="15" x2="44.7812" y2="15" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#d93025"/><stop offset="1" stop-color="#ea4335"/></linearGradient><linearGradient id="b" x1="20.7219" y1="47.6791" x2="41.5039" y2="11.6837" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fcc934"/><stop offset="1" stop-color="#fbbc04"/></linearGradient><linearGradient id="c" x1="26.5981" y1="46.5015" x2="5.8161" y2="10.506" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e8e3e"/><stop offset="1" stop-color="#34a853"/></linearGradient></defs><circle cx="24" cy="23.9947" r="12" style="fill:#fff"/><path d="M3.2154,36A24,24,0,1,0,12,3.2154,24,24,0,0,0,3.2154,36ZM34.3923,18A12,12,0,1,1,18,13.6077,12,12,0,0,1,34.3923,18Z" style="fill:none"/><path d="M24,12H44.7812a23.9939,23.9939,0,0,0-41.5639.0029L13.6079,30l.0093-.0024A11.9852,11.9852,0,0,1,24,12Z" style="fill:url(#a)"/><circle cx="24" cy="24" r="9.5" style="fill:#1a73e8"/><path d="M34.3913,30.0029,24.0007,48A23.994,23.994,0,0,0,44.78,12.0031H23.9989l-.0025.0093A11.985,11.985,0,0,1,34.3913,30.0029Z" style="fill:url(#b)"/><path d="M13.6086,30.0031,3.218,12.006A23.994,23.994,0,0,0,24.0025,48L34.3931,30.0029l-.0067-.0068a11.9852,11.9852,0,0,1-20.7778.007Z" style="fill:url(#c)"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
</svg>

After

Width:  |  Height:  |  Size: 1019 B

+13 -4
View File
@@ -2,8 +2,8 @@ import { redirect } from "next/navigation";
import { LeftRail } from "@/components/shell/LeftRail"; import { LeftRail } from "@/components/shell/LeftRail";
import { ApiAuthError } from "@/lib/api/http"; import { ApiAuthError } from "@/lib/api/http";
import { fetchClaws, fetchMe } from "@/lib/api/team"; import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, Credits, User } from "@/lib/api/schemas";
// Every workspace page shares the persistent rail (§4). This layout reads // Every workspace page shares the persistent rail (§4). This layout reads
// only path-level data — search params stay client-side (see panel-params). // only path-level data — search params stay client-side (see panel-params).
@@ -12,8 +12,13 @@ export default async function WorkspaceLayout({
}: Readonly<{ children: React.ReactNode }>) { }: Readonly<{ children: React.ReactNode }>) {
let user: User; let user: User;
let roster: Agent[]; let roster: Agent[];
let credits: Credits;
try { try {
[user, roster] = await Promise.all([fetchMe(), fetchClaws()]); [user, roster, credits] = await Promise.all([
fetchMe(),
fetchClaws(),
fetchCredits(),
]);
} catch (error) { } catch (error) {
if (error instanceof ApiAuthError) { if (error instanceof ApiAuthError) {
redirect("/login"); redirect("/login");
@@ -22,7 +27,11 @@ export default async function WorkspaceLayout({
} }
return ( return (
<div className="flex min-h-dvh"> <div className="flex min-h-dvh">
<LeftRail user={user} roster={roster} /> <LeftRail
user={user}
roster={roster}
creditsBalance={credits.available}
/>
<main className="min-w-0 flex-1">{children}</main> <main className="min-w-0 flex-1">{children}</main>
</div> </div>
); );
+30 -14
View File
@@ -1,12 +1,15 @@
"use client"; "use client";
import { List, Monitor, SquarePen } 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 { panelParsers } from "@/lib/url/panel-params"; import { panelParsers } from "@/lib/url/panel-params";
/** Chat header (§5b): identity left, sessions/new-session controls right. */ /** Chat header (measured): 80px, transparent, identity left; sessions /
* new-session as 36px round icon buttons; the 42px coral-glow Computer
* launcher on the right. Accessible names unchanged from §5b. */
export function ChatHeader({ export function ChatHeader({
agent, agent,
onNewSession, onNewSession,
@@ -18,8 +21,14 @@ export function ChatHeader({
shallow: true, shallow: true,
}); });
return ( return (
<header className="flex h-14 items-center gap-3 border-b border-border px-4"> <header className="flex h-20 items-center gap-3 pr-6 pl-5">
<Avatar name={agent.name} accent={agent.accent} size="sm" /> <Avatar
name={agent.name}
accent={agent.accent}
size="md"
shape="squircle"
online={agent.status === "online"}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{agent.name}</p> <p className="truncate text-sm font-medium">{agent.name}</p>
<p className="truncate text-xxs text-muted-foreground"> <p className="truncate text-xxs text-muted-foreground">
@@ -28,25 +37,32 @@ export function ChatHeader({
</div> </div>
<button <button
type="button" type="button"
onClick={() => setParams({ app: "home" })} aria-label="Sessions"
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground"
>
Computer
</button>
<button
type="button"
aria-pressed={sessions} aria-pressed={sessions}
onClick={() => setParams({ sessions: !sessions })} onClick={() => setParams({ sessions: !sessions })}
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground" className={`inline-flex size-9 items-center justify-center rounded-radius-button transition-colors duration-normal ease-app ${
sessions
? "bg-surface-warm-muted text-foreground"
: "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
}`}
> >
Sessions <List aria-hidden size={18} />
</button> </button>
<button <button
type="button" type="button"
aria-label="New"
onClick={onNewSession} onClick={onNewSession}
className="rounded-(--radius-button) border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground" className="inline-flex size-9 items-center justify-center rounded-radius-button text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground"
> >
New <SquarePen aria-hidden size={18} />
</button>
<button
type="button"
aria-label="Computer"
onClick={() => setParams({ app: "home" })}
className="inline-flex size-[42px] items-center justify-center rounded-radius-button text-coral shadow-launcher transition-all duration-normal ease-app hover:bg-coral/10 active:scale-95"
>
<Monitor aria-hidden size={20} />
</button> </button>
</header> </header>
); );
+18 -12
View File
@@ -72,18 +72,24 @@ export function ChatWorkspace({
</SlidePanel> </SlidePanel>
<section className="flex min-w-0 flex-1 flex-col"> <section className="flex min-w-0 flex-1 flex-col">
<ChatHeader agent={agent} onNewSession={newSession} /> <ChatHeader agent={agent} onNewSession={newSession} />
{state.messages.length === 0 ? ( <div className="mx-auto flex w-full max-w-3xl min-w-0 flex-1 flex-col overflow-hidden px-4">
<WelcomeState agent={agent} onPick={setDraft} /> {state.messages.length === 0 ? (
) : ( <WelcomeState agent={agent} onPick={setDraft} />
<MessageList messages={state.messages} agent={agent} onDecide={decide} /> ) : (
)} <MessageList
<Composer messages={state.messages}
agentName={agent.name} agent={agent}
disabled={state.streaming} onDecide={decide}
onSend={send} />
draft={draft} )}
onDraftChange={setDraft} <Composer
/> agentName={agent.name}
disabled={state.streaming}
onSend={send}
draft={draft}
onDraftChange={setDraft}
/>
</div>
</section> </section>
<DevicePanel agent={agent} /> <DevicePanel agent={agent} />
</div> </div>
+22 -21
View File
@@ -11,7 +11,9 @@ interface ComposerProps {
onDraftChange?: (value: string) => void; onDraftChange?: (value: string) => void;
} }
/** The chat composer (§5): Enter sends, Shift+Enter inserts a newline. */ /** The chat composer (measured): a floating 24px-radius neutral-800 card
* with an inset hairline ring and soft lift, cream send pill. Enter
* sends, Shift+Enter inserts a newline. */
export function Composer({ export function Composer({
agentName, agentName,
disabled, disabled,
@@ -41,26 +43,25 @@ export function Composer({
} }
return ( return (
<form <form onSubmit={submit} className="pb-4">
onSubmit={submit} <div className="flex items-end gap-2 rounded-[24px] bg-surface-warm-muted px-4 py-3 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-4px_rgba(0,0,0,0.06),inset_0_0_0_1px_rgba(255,255,255,0.063)]">
className="flex items-end gap-2 border-t border-border bg-background px-4 py-3" <textarea
> aria-label={`Message ${agentName}`}
<textarea placeholder={`Message ${agentName}...`}
aria-label={`Message ${agentName}`} value={value}
placeholder={`Message ${agentName}...`} rows={1}
value={value} onChange={(e) => setValue(e.target.value)}
rows={1} onKeyDown={handleKeyDown}
onChange={(e) => setValue(e.target.value)} className="max-h-40 min-h-8 flex-1 resize-none bg-transparent py-1 text-base text-foreground outline-none placeholder:text-subtle-foreground"
onKeyDown={handleKeyDown} />
className="max-h-40 min-h-10 flex-1 resize-y rounded-(--radius) border border-input bg-subtle px-3 py-2 text-sm text-foreground outline-none focus:border-accent" <button
/> type="submit"
<button disabled={disabled || value.trim() === ""}
type="submit" className="rounded-radius-button bg-cream px-4 py-2 text-sm font-medium text-primary-foreground shadow-cta transition-colors duration-normal ease-app hover:bg-white disabled:opacity-40 disabled:shadow-none"
disabled={disabled || value.trim() === ""} >
className="rounded-(--radius-button) bg-accent px-4 py-2 text-sm font-medium text-background shadow-(--shadow-button) hover:bg-coral-light disabled:opacity-40" Send
> </button>
Send </div>
</button>
</form> </form>
); );
} }
+17 -8
View File
@@ -10,11 +10,13 @@ import { StepTrace } from "./steps/StepTrace";
type DecideFn = (approvalId: string, decision: "approve" | "reject") => void; type DecideFn = (approvalId: string, decision: "approve" | "reject") => void;
/* User pill (measured): #1A1A1A fill, radius 24/24/4 (the tucked tail),
1px inset white ring, dual shadow, capped at 75%, right-aligned. */
function UserBubble({ message }: { message: UiMessage }) { function UserBubble({ message }: { message: UiMessage }) {
return ( return (
<div className="flex justify-end"> <div className="flex w-full justify-end motion-safe:animate-[messageSlideIn_var(--duration-normal)_var(--ease-app)]">
<p <p
className={`max-w-[70%] whitespace-pre-wrap rounded-(--radius) bg-surface-warm-muted px-3 py-2 text-sm shadow-(--shadow-bubble) ${ className={`w-fit max-w-full rounded-[24px] rounded-br-[4px] bg-bubble-user px-3.5 py-2.5 text-sm leading-[1.7] whitespace-pre-wrap text-bubble-user-text shadow-bubble-user ring-1 ring-white/[0.07] ring-inset md:max-w-[75%] ${
message.status === "sending" ? "opacity-60" : "" message.status === "sending" ? "opacity-60" : ""
}`} }`}
> >
@@ -24,6 +26,8 @@ function UserBubble({ message }: { message: UiMessage }) {
); );
} }
/* Assistant message (measured): NO bubble — 50px avatar, 15px gap, plain
text flowing directly. */
function AgentMessage({ function AgentMessage({
message, message,
agent, agent,
@@ -34,10 +38,15 @@ function AgentMessage({
onDecide: DecideFn; onDecide: DecideFn;
}) { }) {
return ( return (
<div className="flex gap-2"> <div className="flex items-start gap-[15px] motion-safe:animate-[messageSlideIn_var(--duration-normal)_var(--ease-app)]">
<Avatar name={agent.name} accent={agent.accent} size="sm" /> <Avatar
<div className="min-w-0 flex-1"> name={agent.name}
<p className="whitespace-pre-wrap text-sm leading-relaxed"> accent={agent.accent}
size="chat"
shape="squircle"
/>
<div className="min-w-0 flex-1 pt-3">
<p className="text-sm leading-[1.7] whitespace-pre-wrap text-neutral-200">
{message.text} {message.text}
{message.status === "streaming" && ( {message.status === "streaming" && (
<span <span
@@ -64,7 +73,7 @@ function AgentMessage({
); );
} }
/** The transcript (§5b): user bubbles right, agent messages left. */ /** The transcript (measured): user pills right, bare assistant text left. */
export function MessageList({ export function MessageList({
messages, messages,
agent, agent,
@@ -84,7 +93,7 @@ export function MessageList({
return ( return (
<div <div
aria-live="polite" aria-live="polite"
className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-6" className="flex flex-1 flex-col gap-2 overflow-y-auto py-6"
> >
{messages.map((message) => {messages.map((message) =>
message.role === "user" ? ( message.role === "user" ? (
+27 -16
View File
@@ -1,14 +1,18 @@
import { FileText, Presentation, Sparkles, Sun } from "lucide-react";
import type { LucideIcon } from "lucide-react";
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";
const SUGGESTED_PROMPTS = [ const SUGGESTED_PROMPTS: { label: string; icon: LucideIcon }[] = [
"Create a daily briefing", { label: "Create a daily briefing", icon: Sun },
"Write a report", { label: "Write a report", icon: FileText },
"Make a presentation", { label: "Make a presentation", icon: Presentation },
"What can you do?", { label: "What can you do?", icon: Sparkles },
]; ];
/** Empty-session welcome (§5a). */ /** Empty-session welcome (measured): 126px avatar, 24px/600/-0.6px heading
* with the claw's name in coral, subline, suggestion chips. */
export function WelcomeState({ export function WelcomeState({
agent, agent,
onPick, onPick,
@@ -18,22 +22,29 @@ export function WelcomeState({
}) { }) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]"> <div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 motion-safe:animate-[fade-up_var(--duration-normal)_var(--ease-app)]">
<Avatar name={agent.name} accent={agent.accent} size="lg" /> <Avatar
<h2 className="text-center text-xl font-semibold tracking-tight"> name={agent.name}
Hi, I&apos;m {agent.name}. What can I help with? accent={agent.accent}
size="welcome"
shape="squircle"
/>
<h2 className="text-center text-2xl leading-[33px] font-semibold tracking-[-0.6px]">
Hi, I&apos;m <span className="text-coral">{agent.name}</span>. What can
I help with?
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-base text-muted-foreground">
{agent.job_title} · Shared with your team {agent.job_title} · Shared with your team
</p> </p>
<div className="flex max-w-md flex-wrap justify-center gap-2 pt-2"> <div className="flex max-w-xl flex-wrap justify-center gap-2 pt-2">
{SUGGESTED_PROMPTS.map((prompt) => ( {SUGGESTED_PROMPTS.map(({ label, icon: Icon }) => (
<button <button
key={prompt} key={label}
type="button" type="button"
onClick={() => onPick(prompt)} onClick={() => onPick(label)}
className="rounded-(--radius-button) border border-border bg-subtle px-3 py-1.5 text-xs text-muted-foreground hover:border-accent hover:text-foreground" className="inline-flex h-9 items-center gap-2 rounded-radius-button bg-surface-warm-muted px-3.5 text-xs font-medium text-neutral-200 shadow-bubble ring-1 ring-neutral-700/70 transition-colors duration-normal ease-app ring-inset hover:bg-neutral-700/60 hover:ring-neutral-600"
> >
{prompt} <Icon aria-hidden size={14} className="shrink-0 text-coral" />
{label}
</button> </button>
))} ))}
</div> </div>
@@ -18,7 +18,7 @@ export function StepTrace({ steps }: { steps: UiStep[] }) {
aria-expanded={expanded} aria-expanded={expanded}
aria-controls={regionId} aria-controls={regionId}
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
className="rounded-(--radius) px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-surface-warm hover:text-foreground" className="rounded-radius-button px-2 py-0.5 text-xs text-muted-foreground transition-colors duration-normal ease-app hover:bg-hover-bg hover:text-foreground"
> >
{expanded ? "▾" : "▸"} {steps.length}{" "} {expanded ? "▾" : "▸"} {steps.length}{" "}
{steps.length === 1 ? "step" : "steps"} {steps.length === 1 ? "step" : "steps"}
@@ -28,7 +28,7 @@ export function StepTrace({ steps }: { steps: UiStep[] }) {
{steps.map((step) => ( {steps.map((step) => (
<li <li
key={step.seq} key={step.seq}
className="rounded-(--radius) border border-border bg-subtle px-2 py-1.5 font-mono text-xs motion-safe:animate-[toolSlideIn_var(--duration-fast)_var(--ease-app)]" className="rounded-xl bg-surface-warm px-2.5 py-1.5 font-mono text-xs ring-1 ring-white/[0.05] ring-inset motion-safe:animate-[toolSlideIn_var(--duration-fast)_var(--ease-app)]"
> >
<span <span
className={ className={
+19 -8
View File
@@ -2,13 +2,14 @@ import type { CSSProperties, ReactNode } from "react";
import type { Agent } from "@/lib/api/schemas"; import type { Agent } from "@/lib/api/schemas";
/** Derives the per-agent panel theme (§7: "themed per-agent"). Art assets /** Derives the per-agent panel theme (measured). The base wallpaper is a
* are excluded by spec, so wallpapers are accent-derived gradients. */ * 135° gradient like the reference (slate/navy by default, tinted toward
* the agent's accent); art assets are excluded by spec. */
export function clawThemeStyle(agent: Agent): CSSProperties { export function clawThemeStyle(agent: Agent): CSSProperties {
const accent = agent.accent || "#f96565"; const accent = agent.accent || "#f96565";
return { return {
"--agent-accent": accent, "--agent-accent": accent,
"--agent-wallpaper": `linear-gradient(160deg, color-mix(in srgb, ${accent} 24%, #0a0a0a) 0%, #101010 55%, color-mix(in srgb, ${accent} 10%, #0a0a0a) 100%)`, "--agent-wallpaper": `linear-gradient(135deg, color-mix(in srgb, ${accent} 20%, #0f172a) 0%, #1e293b 100%)`,
} as CSSProperties; } as CSSProperties;
} }
@@ -25,14 +26,24 @@ export function GrainOverlay() {
); );
} }
/** The measured wallpaper stack, back→front: base gradient, a #000/.40
* vignette, and a frosted glass overlay (blur 40px over white/5). Grain
* rides on top; children sit above it all. */
export function WallpaperSurface({ children }: { children: ReactNode }) { export function WallpaperSurface({ children }: { children: ReactNode }) {
return ( return (
<div <div className="relative flex h-full flex-col overflow-hidden">
className="relative flex h-full flex-col" <div
style={{ background: "var(--agent-wallpaper)" }} aria-hidden
> className="absolute inset-0 -z-20"
style={{ background: "var(--agent-wallpaper)" }}
/>
<div aria-hidden className="absolute inset-0 -z-10 bg-black/40" />
<div
aria-hidden
className="absolute inset-0 bg-white/5 opacity-[0.36] backdrop-blur-[40px]"
/>
<GrainOverlay /> <GrainOverlay />
{children} <div className="relative z-0 flex h-full flex-col">{children}</div>
</div> </div>
); );
} }
@@ -0,0 +1,42 @@
import {
Bell,
Folder,
Globe,
MessageCircle,
Plus,
Settings,
Slack,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { AppId } from "@/lib/url/panel-params";
/** The lucide glyph per panel app (measured icon manifest). Browser/Slack
* use their brand marks on the home tiles; everything else is a
* coral-gradient lucide glyph. */
export const APP_ICON: Record<string, LucideIcon> = {
browser: Globe,
slack: Slack,
chat: MessageCircle,
apps: Plus,
skills: Zap,
files: Folder,
routines: Bell,
scheduled: Bell,
settings: Settings,
};
export const HOME_GRID: { app: AppId; label: string }[] = [
{ app: "browser", label: "Browser" },
{ app: "slack", label: "Slack" },
{ app: "chat", label: "Claw Chat" },
{ app: "apps", label: "Add" },
];
export const HOME_DOCK: { app: AppId; label: string }[] = [
{ app: "skills", label: "Skills" },
{ app: "files", label: "Files" },
{ app: "routines", label: "Routines" },
{ app: "settings", label: "Settings" },
];
@@ -3,23 +3,51 @@ import Link from "next/link";
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";
/** One agent row in the left rail (§4): avatar, name, online dot. Links to /** One claw in the rail stack (measured): 48px squircle avatar with a 2px
* the claw's chat (latest session resumes, or a fresh one opens). */ * coral ring when its chat is open, name beneath, online dot. */
export function AgentRosterItem({ agent }: { agent: Agent }) { export function AgentRosterItem({
agent,
active = false,
}: {
agent: Agent;
active?: boolean;
}) {
return ( return (
<Link <Link
href={`/claws/${agent.id}`} href={`/claws/${agent.id}`}
className="flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-foreground hover:bg-surface-warm" aria-current={active ? "page" : undefined}
className="group flex w-full flex-col items-center gap-1 py-1.5"
> >
<Avatar name={agent.name} accent={agent.accent} size="sm" /> <span
<span className="truncate">{agent.name}</span> className={`rounded-[19px] p-0.5 transition-colors duration-normal ease-app ${
{agent.status === "online" && ( active ? "ring-2 ring-coral" : ""
<span }`}
role="status" >
aria-label={`${agent.name} is online`} <span className="relative inline-flex">
className="ml-auto size-1.5 shrink-0 rounded-full bg-green-500" <Avatar
/> name={agent.name}
)} accent={agent.accent}
size="rail"
shape="squircle"
/>
{agent.status === "online" && (
<span
role="status"
aria-label={`${agent.name} is online`}
className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-background bg-green-500"
/>
)}
</span>
</span>
<span
className={`max-w-full truncate text-xs transition-colors duration-normal ease-app ${
active
? "text-foreground"
: "text-muted-foreground group-hover:text-foreground"
}`}
>
{agent.name}
</span>
</Link> </Link>
); );
} }
@@ -1,20 +1,46 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { Users } from "lucide-react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { GlobalNavItem } from "./GlobalNavItem"; import { GlobalNavItem } from "./GlobalNavItem";
describe("GlobalNavItem", () => { describe("GlobalNavItem", () => {
it("marks the active item with aria-current and the coral accent", () => { it("marks the active item with aria-current, bright text, coral icon", () => {
render(<GlobalNavItem href="/team" label="Team" active />); const { container } = render(
<GlobalNavItem href="/team" label="Team" icon={Users} active />,
);
const link = screen.getByRole("link", { name: "Team" }); const link = screen.getByRole("link", { name: "Team" });
expect(link).toHaveAttribute("aria-current", "page"); expect(link).toHaveAttribute("aria-current", "page");
expect(link.className).toContain("text-accent"); expect(link.className).toContain("text-foreground");
expect(container.querySelector("svg")!.getAttribute("class")).toContain(
"text-coral",
);
}); });
it("renders inactive items muted without aria-current", () => { it("renders inactive items muted without aria-current", () => {
render(<GlobalNavItem href="/credits" label="Credits" active={false} />); render(
<GlobalNavItem
href="/credits"
label="Credits"
icon={Users}
active={false}
/>,
);
const link = screen.getByRole("link", { name: "Credits" }); const link = screen.getByRole("link", { name: "Credits" });
expect(link).not.toHaveAttribute("aria-current"); expect(link).not.toHaveAttribute("aria-current");
expect(link.className).toContain("text-muted-foreground"); expect(link.className).toContain("text-muted-foreground");
}); });
it("shows trailing content like the credits balance", () => {
render(
<GlobalNavItem
href="/credits"
label="Credits"
icon={Users}
active={false}
trailing={<span>1,250</span>}
/>,
);
expect(screen.getByText("1,250")).toBeInTheDocument();
});
}); });
@@ -1,24 +1,47 @@
import Link from "next/link"; import Link from "next/link";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
interface GlobalNavItemProps { interface GlobalNavItemProps {
href: string; href: string;
label: string; label: string;
icon: LucideIcon;
/** Computed by the shell from the current pathname; active uses coral (§4). */ /** Computed by the shell from the current pathname; active uses coral (§4). */
active: boolean; active: boolean;
/** Inline trailing content (e.g. the credits balance). */
trailing?: ReactNode;
} }
export function GlobalNavItem({ href, label, active }: GlobalNavItemProps) { /* Rail nav pill (measured): h-32px, icon+label gap 12px, full radius,
hover #171717; the active route brightens and its icon goes coral. */
export function GlobalNavItem({
href,
label,
icon: Icon,
active,
trailing,
}: GlobalNavItemProps) {
return ( return (
<Link <Link
href={href} href={href}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`block rounded-(--radius) px-2 py-1.5 text-sm transition-colors duration-(--duration-fast) ${ className={`flex h-8 items-center gap-3 rounded-radius-button px-3 text-base transition-colors duration-normal ease-app ${
active active
? "text-accent" ? "text-foreground"
: "text-muted-foreground hover:bg-surface-warm hover:text-foreground" : "text-muted-foreground hover:bg-hover-bg hover:text-foreground"
}`} }`}
> >
{label} <Icon
aria-hidden
size={16}
className={`shrink-0 ${active ? "text-coral" : ""}`}
/>
<span className="truncate">{label}</span>
{trailing != null && (
<span aria-hidden className="ml-auto text-sm">
{trailing}
</span>
)}
</Link> </Link>
); );
} }
+18 -13
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { Plus } from "lucide-react";
import type { Agent, User } from "@/lib/api/schemas"; import type { Agent, User } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem"; import { AgentRosterItem } from "./AgentRosterItem";
@@ -8,21 +9,23 @@ import { UserMenu } from "./UserMenu";
interface LeftRailProps { interface LeftRailProps {
user: User; user: User;
roster: Agent[]; roster: Agent[];
creditsBalance?: number;
} }
/** The persistent 176px left rail (§4): logo, agent roster, global nav, /** The persistent 176px left rail (measured): wordmark, the claw avatar
* current user pinned to the bottom. */ * stack (48px squircles), dashed add tile, then global nav + the current
export function LeftRail({ user, roster }: LeftRailProps) { * user pinned to the bottom. */
export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
return ( return (
<aside className="flex h-dvh w-rail shrink-0 flex-col border-r border-border bg-subtle px-2 py-4"> <aside className="flex h-dvh w-rail shrink-0 flex-col px-1 py-4">
<p className="px-2 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground"> <p className="px-3 pb-4 font-mono text-sm font-semibold tracking-tight text-foreground">
clawmates clawmates
</p> </p>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{roster.length === 0 ? ( {roster.length === 0 ? (
<p className="px-2 text-xs text-muted-foreground">No claws yet</p> <p className="px-3 text-xs text-muted-foreground">No claws yet</p>
) : ( ) : (
<ul aria-label="Your claws" className="flex flex-col gap-0.5"> <ul aria-label="Your claws" className="flex flex-col">
{roster.map((agent) => ( {roster.map((agent) => (
<li key={agent.id}> <li key={agent.id}>
<AgentRosterItem agent={agent} /> <AgentRosterItem agent={agent} />
@@ -32,19 +35,21 @@ export function LeftRail({ user, roster }: LeftRailProps) {
)} )}
<Link <Link
href="/claws/new" href="/claws/new"
className="mt-1 flex items-center gap-2 rounded-(--radius) px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-warm hover:text-foreground" className="group mt-1 flex w-full flex-col items-center gap-1 py-1.5"
> >
<span <span
aria-hidden aria-hidden
className="inline-flex size-6 items-center justify-center rounded-full border border-dashed border-border" 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> </span>
New claw
</Link> </Link>
</div> </div>
<div className="flex flex-col gap-3 pt-3"> <div className="flex flex-col gap-3 px-1 pt-3">
<ShellNav /> <ShellNav creditsBalance={creditsBalance} />
<UserMenu user={user} /> <UserMenu user={user} />
</div> </div>
</aside> </aside>
@@ -0,0 +1,24 @@
"use client";
import { usePathname } from "next/navigation";
import type { Agent } from "@/lib/api/schemas";
import { AgentRosterItem } from "./AgentRosterItem";
/** Client wrapper computing which claw's chat is open from the pathname
* (mirrors ShellNav) so the roster item itself stays presentational. */
export function RosterList({ roster }: { roster: Agent[] }) {
const pathname = usePathname();
return (
<ul aria-label="Your claws" className="flex flex-col">
{roster.map((agent) => (
<li key={agent.id}>
<AgentRosterItem
agent={agent}
active={pathname.startsWith(`/claws/${agent.id}`)}
/>
</li>
))}
</ul>
);
}
+23 -8
View File
@@ -1,19 +1,20 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { CreditCard, ShieldCheck, Users, Zap } from "lucide-react";
import { GlobalNavItem } from "./GlobalNavItem"; import { GlobalNavItem } from "./GlobalNavItem";
/** Global nav entries that exist in the current phase. Skills and Apps join
* the rail when their pages land (spec §17 P1/P5). */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/skills", label: "Skills" }, { href: "/skills", label: "Skills", icon: Zap },
{ href: "/approvals", label: "Approvals" }, { href: "/approvals", label: "Approvals", icon: ShieldCheck },
{ href: "/team", label: "Team" }, { href: "/team", label: "Team", icon: Users },
{ href: "/credits", label: "Credits" }, { href: "/credits", label: "Credits", icon: CreditCard },
]; ];
export function ShellNav() { /** Global nav pinned under the roster; Credits shows the live balance
* inline (coral when negative, per the measured rail spec). */
export function ShellNav({ creditsBalance }: { creditsBalance?: number }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav aria-label="Global" className="flex flex-col gap-0.5"> <nav aria-label="Global" className="flex flex-col gap-0.5">
@@ -22,7 +23,21 @@ export function ShellNav() {
key={item.href} key={item.href}
href={item.href} href={item.href}
label={item.label} label={item.label}
active={pathname === item.href || pathname.startsWith(`${item.href}/`)} icon={item.icon}
active={
pathname === item.href || pathname.startsWith(`${item.href}/`)
}
trailing={
item.href === "/credits" && creditsBalance !== undefined ? (
<span
className={
creditsBalance < 0 ? "text-coral" : "text-muted-foreground"
}
>
{creditsBalance.toLocaleString("en-US")}
</span>
) : undefined
}
/> />
))} ))}
</nav> </nav>