Left rail restructure + Computer panel as a right-anchored overlay
PART 2 — panel positioning (the 'pushes the chat left' root cause): DevicePanel was an in-flow flex sibling, so opening it consumed width and shoved the chat. Now phone/tablet anchor it as an ABSOLUTE right-edge overlay (md:absolute right-0 top-0 h-full z-30, w-[448px]/w-[550px]) so the chat keeps full width and does NOT reflow; only 'full' stays in-flow (grow-[4]) and lets the chat shrink. The chat row is now relative; the aside is pointer-events-none with the screen card pointer-events-auto so the header toggles/close stay clickable under the overlay's transparent top padding. Verified live: composer.left is identical (472px) panel open vs closed in tablet — the chat no longer shifts. PART 1 — left rail: - Logo in its own 80px header row as a 48px black rounded-2xl brand tile with a coral PawPrint mark (our placeholder logo) + sr-only 'clawmates'. - Agent tiles bumped to 58px (Avatar 'rail' size + rounded-[20px] squircle), centered, gap-1, 2px coral active ring (rounded-[22px]). - Hover-revealed ⋯ menu per agent row (new AgentRowMenu): a 176px #1A1A1A popover with Pin + Settings rows (coral 15px icons, 14px labels), closes on outside-click/Escape. Pin pins the claw to the top of the rail (persisted in localStorage, client-side); Settings deep-links to that claw's Computer → Settings (the claw redirect now forwards ?app=). 86 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
9456edf198
commit
96e0134fc6
@@ -4,16 +4,22 @@ import { createSession, fetchSessions } from "@/lib/api/sessions";
|
|||||||
import { encodeSessionKeyParam } from "@/lib/url/session-key";
|
import { encodeSessionKeyParam } from "@/lib/url/session-key";
|
||||||
|
|
||||||
// Clicking a claw in the rail lands here: resume the most recent session,
|
// Clicking a claw in the rail lands here: resume the most recent session,
|
||||||
// or open a fresh one (sessions are resumable, §6).
|
// or open a fresh one (sessions are resumable, §6). An optional ?app= is
|
||||||
|
// forwarded so the rail's per-claw "Settings" deep-link opens the Computer
|
||||||
|
// Settings app on landing.
|
||||||
export default async function ClawHome({
|
export default async function ClawHome({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ clawId: string }>;
|
params: Promise<{ clawId: string }>;
|
||||||
|
searchParams: Promise<{ app?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { clawId } = await params;
|
const { clawId } = await params;
|
||||||
|
const { app } = await searchParams;
|
||||||
const sessions = await fetchSessions(clawId);
|
const sessions = await fetchSessions(clawId);
|
||||||
const target = sessions[0] ?? (await createSession(clawId));
|
const target = sessions[0] ?? (await createSession(clawId));
|
||||||
|
const suffix = app ? `?app=${encodeURIComponent(app)}` : "";
|
||||||
redirect(
|
redirect(
|
||||||
`/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}`,
|
`/claws/${clawId}/chat/${encodeSessionKeyParam(target.sessionKey)}${suffix}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function ChatWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh">
|
<div className="relative flex h-dvh">
|
||||||
<SlidePanel open={showSessions} width={208} label="Sessions">
|
<SlidePanel open={showSessions} width={208} label="Sessions">
|
||||||
<SessionsColumn
|
<SessionsColumn
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
|||||||
@@ -15,15 +15,20 @@ 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 (measured): phone = 448px aside / 400 card, tablet =
|
/* Device-state sizing + positioning model (measured). Phone/tablet anchor
|
||||||
550 / 502, full = fluid (the panel drops its fixed width and fills the
|
the panel as an ABSOLUTE right-edge overlay so the chat keeps its full
|
||||||
row beside the chat). Driven by flex so "full" is responsive; the grow
|
width and doesn't reflow when the panel opens (448/550px aside → 400/502
|
||||||
animates with the signature easing. The card is w-full inside a px-6
|
card). Only "full" is an in-flow flex sibling that lets the chat shrink
|
||||||
wrapper, so card width = aside − 48. */
|
(basis-0 grow-[4] → fluid, fills the row). The card is w-full inside a
|
||||||
const SIZING: Record<DeviceSize, string> = {
|
px-6 wrapper, so card width = aside − 48. */
|
||||||
phone: "basis-[448px] grow-0",
|
const OVERLAY = "md:absolute md:top-0 md:right-0 md:z-30 md:h-full";
|
||||||
tablet: "basis-[550px] grow-0",
|
const SIZING: Record<DeviceSize, { open: string; closed: string }> = {
|
||||||
full: "basis-0 grow-[4]",
|
phone: { open: `${OVERLAY} md:w-[448px]`, closed: `${OVERLAY} md:w-0` },
|
||||||
|
tablet: { open: `${OVERLAY} md:w-[550px]`, closed: `${OVERLAY} md:w-0` },
|
||||||
|
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
|
||||||
@@ -49,7 +54,9 @@ export function DevicePanel({ agent }: { agent: Agent }) {
|
|||||||
if (
|
if (
|
||||||
!open &&
|
!open &&
|
||||||
event.target === event.currentTarget &&
|
event.target === event.currentTarget &&
|
||||||
(event.propertyName === "flex-basis" || event.propertyName === "flex-grow")
|
(event.propertyName === "width" ||
|
||||||
|
event.propertyName === "flex-basis" ||
|
||||||
|
event.propertyName === "flex-grow")
|
||||||
) {
|
) {
|
||||||
setMounted(false);
|
setMounted(false);
|
||||||
}
|
}
|
||||||
@@ -75,8 +82,8 @@ export function DevicePanel({ agent }: { agent: Agent }) {
|
|||||||
aria-label="Computer"
|
aria-label="Computer"
|
||||||
aria-hidden={!open}
|
aria-hidden={!open}
|
||||||
onTransitionEnd={handleTransitionEnd}
|
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 ${
|
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 ${
|
||||||
open ? SIZING[device] : "basis-0 grow-0"
|
open ? SIZING[device].open : SIZING[device].closed
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +93,7 @@ export function DevicePanel({ agent }: { agent: Agent }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
className="relative flex h-full w-full flex-col overflow-hidden rounded-[32px] shadow-screen max-md:rounded-none max-md:shadow-none"
|
className="pointer-events-auto relative flex h-full w-full flex-col overflow-hidden rounded-[32px] shadow-screen max-md:rounded-none max-md:shadow-none"
|
||||||
>
|
>
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<WallpaperSurface>
|
<WallpaperSurface>
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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";
|
||||||
|
import { AgentRowMenu } from "./AgentRowMenu";
|
||||||
|
|
||||||
/** One claw in the rail stack (measured): 48px squircle avatar with a 2px
|
/** One claw in the rail stack (measured): 58px squircle avatar with a 2px
|
||||||
* coral ring when its chat is open, name beneath, online dot. */
|
* coral ring when its chat is open, name beneath, online dot, and a
|
||||||
|
* hover-revealed ⋯ menu (Pin / Settings). */
|
||||||
export function AgentRosterItem({
|
export function AgentRosterItem({
|
||||||
agent,
|
agent,
|
||||||
active = false,
|
active = false,
|
||||||
|
pinned = false,
|
||||||
|
onTogglePin = () => {},
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
pinned?: boolean;
|
||||||
|
onTogglePin?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
<div className="group relative flex flex-col items-center">
|
||||||
<Link
|
<Link
|
||||||
href={`/claws/${agent.id}`}
|
href={`/claws/${agent.id}`}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
className="group flex w-full flex-col items-center gap-1 py-1.5"
|
className="flex w-full flex-col items-center gap-1"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`rounded-[19px] p-0.5 transition-colors duration-(--duration-normal) ease-app ${
|
className={`rounded-[22px] p-0.5 transition-colors duration-(--duration-normal) ease-app ${
|
||||||
active ? "ring-2 ring-coral" : ""
|
active ? "ring-2 ring-coral" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -49,5 +58,12 @@ export function AgentRosterItem({
|
|||||||
{agent.name}
|
{agent.name}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<AgentRowMenu
|
||||||
|
agentId={agent.id}
|
||||||
|
agentName={agent.name}
|
||||||
|
pinned={pinned}
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { AgentRowMenu } from "./AgentRowMenu";
|
||||||
|
|
||||||
|
describe("AgentRowMenu", () => {
|
||||||
|
it("opens on the ⋯ trigger and offers Pin + Settings", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<AgentRowMenu
|
||||||
|
agentId="claw-1"
|
||||||
|
agentName="Scout"
|
||||||
|
pinned={false}
|
||||||
|
onTogglePin={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Options for Scout" }));
|
||||||
|
expect(screen.getByRole("menuitem", { name: "Pin" })).toBeInTheDocument();
|
||||||
|
const settings = screen.getByRole("menuitem", { name: "Settings" });
|
||||||
|
expect(settings).toHaveAttribute("href", "/claws/claw-1?app=settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the pin and shows Unpin when already pinned", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onTogglePin = vi.fn();
|
||||||
|
render(
|
||||||
|
<AgentRowMenu
|
||||||
|
agentId="claw-1"
|
||||||
|
agentName="Scout"
|
||||||
|
pinned
|
||||||
|
onTogglePin={onTogglePin}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Options for Scout" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Unpin" }));
|
||||||
|
expect(onTogglePin).toHaveBeenCalledWith("claw-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MoreHorizontal, Pin, PinOff, Settings } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/** The hover-revealed ⋯ menu on each rail agent row (measured): a 176px
|
||||||
|
* #1A1A1A popover with Pin + Settings rows (coral 15px icons). Pin toggles
|
||||||
|
* the claw's pinned state; Settings deep-links into its Computer Settings. */
|
||||||
|
export function AgentRowMenu({
|
||||||
|
agentId,
|
||||||
|
agentName,
|
||||||
|
pinned,
|
||||||
|
onTogglePin,
|
||||||
|
}: {
|
||||||
|
agentId: string;
|
||||||
|
agentName: string;
|
||||||
|
pinned: boolean;
|
||||||
|
onTogglePin: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onDown(event: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const rowClass =
|
||||||
|
"flex h-10 w-full items-center gap-3 rounded-xl px-3 text-sm text-neutral-200 transition-colors duration-(--duration-normal) ease-app hover:bg-hover-bg";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="absolute top-0 right-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Options for ${agentName}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={`inline-flex size-5 items-center justify-center rounded-md text-muted-foreground transition-opacity duration-(--duration-normal) ease-app hover:text-foreground ${
|
||||||
|
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<MoreHorizontal aria-hidden size={14} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
aria-label={`${agentName} options`}
|
||||||
|
className="absolute top-6 right-0 z-50 w-44 rounded-2xl bg-[#1a1a1a] p-1.5 shadow-popover ring-1 ring-white/[0.06]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
onTogglePin(agentId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
|
{pinned ? (
|
||||||
|
<PinOff aria-hidden size={15} className="text-coral" />
|
||||||
|
) : (
|
||||||
|
<Pin aria-hidden size={15} className="text-coral" />
|
||||||
|
)}
|
||||||
|
{pinned ? "Unpin" : "Pin"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
role="menuitem"
|
||||||
|
href={`/claws/${agentId}?app=settings`}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
|
<Settings aria-hidden size={15} className="text-coral" />
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Menu, 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";
|
||||||
@@ -49,24 +49,29 @@ export function LeftRail({ user, roster, creditsBalance }: LeftRailProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
className={`z-50 flex h-dvh w-rail shrink-0 flex-col px-1 py-4 transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:bg-background max-md:shadow-edge ${
|
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 ${
|
||||||
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-3 pb-4">
|
{/* The logo lives in its own 80px header row (measured): a 48px
|
||||||
<p className="font-mono text-sm font-semibold tracking-tight text-foreground">
|
black squircle brand tile with our coral mark. */}
|
||||||
clawmates
|
<header className="flex h-20 items-center pl-4">
|
||||||
</p>
|
<Link href="/" aria-label="clawmates" className="inline-flex">
|
||||||
|
<span className="flex size-12 items-center justify-center rounded-2xl bg-black">
|
||||||
|
<PawPrint aria-hidden size={26} className="text-coral" />
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">clawmates</span>
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close navigation"
|
aria-label="Close navigation"
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="hidden text-muted-foreground max-md:inline-flex"
|
className="ml-auto hidden pr-3 text-muted-foreground max-md:inline-flex"
|
||||||
>
|
>
|
||||||
<X aria-hidden size={16} />
|
<X aria-hidden size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</header>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
{roster.length === 0 ? (
|
{roster.length === 0 ? (
|
||||||
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
|
<p className="px-3 text-xs text-muted-foreground">No claws yet</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,21 +1,64 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import type { Agent } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import { AgentRosterItem } from "./AgentRosterItem";
|
import { AgentRosterItem } from "./AgentRosterItem";
|
||||||
|
|
||||||
/** Client wrapper computing which claw's chat is open from the pathname
|
const PIN_KEY = "clawmates:pinned";
|
||||||
* (mirrors ShellNav) so the roster item itself stays presentational. */
|
|
||||||
|
/** Client wrapper: computes the active claw from the pathname (mirrors
|
||||||
|
* ShellNav) and owns the locally-persisted pin order so the roster item
|
||||||
|
* itself stays presentational. */
|
||||||
export function RosterList({ roster }: { roster: Agent[] }) {
|
export function RosterList({ roster }: { roster: Agent[] }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [pinned, setPinned] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Pins are a per-browser preference (no backend) — hydrate after mount to
|
||||||
|
// avoid a server/client mismatch.
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PIN_KEY);
|
||||||
|
// Hydrate the per-browser pin order after mount (server renders the
|
||||||
|
// default order; this reorders client-side only).
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
if (raw) setPinned(JSON.parse(raw) as string[]);
|
||||||
|
} catch {
|
||||||
|
// ignore malformed storage
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function togglePin(id: string) {
|
||||||
|
setPinned((prev) => {
|
||||||
|
const next = prev.includes(id)
|
||||||
|
? prev.filter((x) => x !== id)
|
||||||
|
: [...prev, id];
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PIN_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinnedSet = new Set(pinned);
|
||||||
|
const ordered = [...roster].sort((a, b) => {
|
||||||
|
const ap = pinnedSet.has(a.id) ? pinned.indexOf(a.id) : Infinity;
|
||||||
|
const bp = pinnedSet.has(b.id) ? pinned.indexOf(b.id) : Infinity;
|
||||||
|
return ap - bp; // pinned first (in pin order); the rest keep server order
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul aria-label="Your claws" className="flex flex-col">
|
<ul aria-label="Your claws" className="flex flex-col items-center gap-1">
|
||||||
{roster.map((agent) => (
|
{ordered.map((agent) => (
|
||||||
<li key={agent.id}>
|
<li key={agent.id} className="w-full">
|
||||||
<AgentRosterItem
|
<AgentRosterItem
|
||||||
agent={agent}
|
agent={agent}
|
||||||
active={pathname.startsWith(`/claws/${agent.id}`)}
|
active={pathname.startsWith(`/claws/${agent.id}`)}
|
||||||
|
pinned={pinnedSet.has(agent.id)}
|
||||||
|
onTogglePin={togglePin}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ describe("Avatar", () => {
|
|||||||
expect(container.textContent).toBe("SL");
|
expect(container.textContent).toBe("SL");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("squircle shape uses the 17px squircle radius instead of a circle", () => {
|
it("squircle shape uses a squircle radius instead of a circle", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Avatar name="Scout" shape="squircle" size="rail" />,
|
<Avatar name="Scout" shape="squircle" size="rail" />,
|
||||||
);
|
);
|
||||||
const span = container.querySelector("span[aria-hidden]")!;
|
const span = container.querySelector("span[aria-hidden]")!;
|
||||||
expect(span.className).toContain("rounded-[17px]");
|
expect(span.className).toContain("rounded-[20px]");
|
||||||
expect(span.className).not.toContain("rounded-full");
|
expect(span.className).not.toContain("rounded-full");
|
||||||
expect(span.className).toContain("size-12"); /* 48px rail size */
|
expect(span.className).toContain("size-[58px]"); /* 58px rail tile */
|
||||||
});
|
});
|
||||||
|
|
||||||
it("welcome size renders the 126px hero avatar", () => {
|
it("welcome size renders the 126px hero avatar", () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const SIZES = {
|
|||||||
sm: "size-6 text-xxs",
|
sm: "size-6 text-xxs",
|
||||||
md: "size-8 text-xs",
|
md: "size-8 text-xs",
|
||||||
lg: "size-16 text-xl",
|
lg: "size-16 text-xl",
|
||||||
rail: "size-12 text-base" /* 48px 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 */,
|
||||||
welcome: "size-[126px] text-4xl" /* empty-state hero */,
|
welcome: "size-[126px] text-4xl" /* empty-state hero */,
|
||||||
@@ -22,6 +22,7 @@ const SIZES = {
|
|||||||
/* Squircle radius scales with the tile so big avatars read as rounded as
|
/* Squircle radius scales with the tile so big avatars read as rounded as
|
||||||
the 48px rail tile (a flat 17px looks square at 126px). */
|
the 48px rail tile (a flat 17px looks square at 126px). */
|
||||||
const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = {
|
const SQUIRCLE_RADIUS: Partial<Record<keyof typeof SIZES, string>> = {
|
||||||
|
rail: "rounded-[20px]",
|
||||||
header: "rounded-[20px]",
|
header: "rounded-[20px]",
|
||||||
welcome: "rounded-[40px]",
|
welcome: "rounded-[40px]",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user