Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ Top to bottom:
workbenches, and each is its own top-level route (`/mission-control`,
`/routines`, `/files`, `/skills`, `/agents`, `/plugins`, `/insights`,
`/evals`).
4. **Account row** — avatar and name, anchoring the rail. The whole row is
a menu trigger (weekly usage, Settings, feedback, log out) that pops
upward. Settings is reached only through this menu — it has no rail
icon of its own.
4. **Account row** — avatar and name, anchoring the rail, plus a separate
settings icon beside it. The avatar+name half is a menu trigger
(weekly usage, feedback, log out) that pops upward; the gear is a
direct one-click control to Settings, not a menu item — Settings
never cost two clicks to reach.

A workbench is an agent conversation, and the bench list IS the switcher —
its rows are the primary way to move between workbenches, with no separate
Expand Down Expand Up @@ -116,6 +117,15 @@ and the rest of its semantic palette. Never hardcode a hex value or an
arbitrary Tailwind color class in product code; if a needed token doesn't
exist yet, add it in react-ui, not locally.

**Generated identity color** is the one deliberate exception: a person's
fallback avatar (no explicit picture) needs a color per principal, not a
handful of shared tokens, so it's the same `colorForPrincipal` hash
already shipped for presence cursors (`@corbits/presence/color`), paired
with a computed black/white initials color for contrast
(`@corbits/chat-ui`'s `generatedAvatarStyle`). Agents keep react-ui's
`Avatar` tone system (solid `--primary`/`--accent`/`--success`) so the two
identity kinds stay visually distinct at a glance.

**Type.** Red Hat Display for sans (UI text, headings), Space Mono for
monospace (code, IDs, numeric/tabular contexts). Both are declared once in
`apps/web/src/tailwind.css`'s `@theme` block; consumers use `font-sans` /
Expand Down
38 changes: 34 additions & 4 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1321,17 +1321,47 @@ select:disabled,
outline-offset: -1px;
}

.shell-sidebar-account-btn [data-slot="avatar"] {
/* `Avatar`'s own root span carries `role="img"`, not a `data-slot` — this
used to target a `[data-slot="avatar"]` that react-ui's `Avatar` has
never actually rendered, so the account avatar silently fell back to
react-ui's own default sizing/shape. Geometry only: background/text
color come from the generated per-person fill
(`.avatar-identity-generated`, lower specificity, so it still wins). */
.shell-sidebar-account-btn [role="img"] {
flex-shrink: 0;
width: 2.1rem;
height: 2.1rem;
border-radius: 0;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
color: var(--foreground);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
}

.shell-sidebar-account-row {
display: flex;
align-items: center;
gap: 0.3rem;
width: 100%;
}

.shell-sidebar-account-row .shell-sidebar-account-btn {
width: auto;
flex: 1;
min-width: 0;
}

.shell-sidebar-settings-btn {
flex-shrink: 0;
min-width: 2.5rem;
min-height: 2.5rem;
color: var(--muted-foreground);
}

.shell-sidebar-settings-btn:hover,
.shell-sidebar-settings-btn[data-active="true"] {
color: var(--foreground);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}

.shell-sidebar-account-name {
min-width: 0;
flex: 1;
Expand Down
88 changes: 51 additions & 37 deletions apps/web/src/shell/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ import {
SlidersHorizontal,
SquaresFour,
} from "@corbits/icons";
import type { CSSProperties } from "react";
import { useMemo } from "react";

import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "@corbits/chat-ui";
import {
createInsightsWindow,
formatUsd,
Expand Down Expand Up @@ -248,43 +250,55 @@ export function Sidebar({
<span>Evals</span>
</button>

<Menu>
<MenuTrigger asChild>
<button
type="button"
className="shell-sidebar-account-btn"
aria-label={`${user.name} · Account menu`}
title={user.name}
data-ctx-account=""
>
<Avatar
initials={initialsOf(user.name)}
label={user.name}
size="sm"
tone="neutral"
/>
<span className="shell-sidebar-account-name">{user.name}</span>
</button>
</MenuTrigger>
<MenuContent align="start" side="top">
<WeeklyUsageMenuItem onNavigate={onNavigate} />
<MenuItem onSelect={() => onNavigate(SETTINGS_PATH)}>
<SlidersHorizontal /> Settings
</MenuItem>
<MenuItem asChild>
<a href={FEEDBACK_URL} target="_blank" rel="noreferrer">
<ChatCircleDots /> Send Feedback
</a>
</MenuItem>
<MenuSeparator />
<MenuItem
onSelect={onSignOut}
className="text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive"
>
<SignOut /> Log out
</MenuItem>
</MenuContent>
</Menu>
<div className="shell-sidebar-account-row">
<Menu>
<MenuTrigger asChild>
<button
type="button"
className="shell-sidebar-account-btn"
aria-label={`${user.name} · Account menu`}
title={user.name}
data-ctx-account=""
style={generatedAvatarStyle(user.id) as CSSProperties}
>
<Avatar
initials={initialsOf(user.name)}
label={user.name}
size="sm"
tone="neutral"
className={AVATAR_IDENTITY_CLASS}
/>
<span className="shell-sidebar-account-name">{user.name}</span>
</button>
</MenuTrigger>
<MenuContent align="start" side="top">
<WeeklyUsageMenuItem onNavigate={onNavigate} />
<MenuItem asChild>
<a href={FEEDBACK_URL} target="_blank" rel="noreferrer">
<ChatCircleDots /> Send Feedback
</a>
</MenuItem>
<MenuSeparator />
<MenuItem
onSelect={onSignOut}
className="text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive"
>
<SignOut /> Log out
</MenuItem>
</MenuContent>
</Menu>
<Button
variant="ghost"
size="icon"
className="shell-sidebar-settings-btn"
aria-label="Settings"
title="Settings"
data-active={matchesRoute(SETTINGS_PATH, path) ? "true" : undefined}
onClick={() => onNavigate(SETTINGS_PATH)}
>
<SlidersHorizontal />
</Button>
</div>
</SidebarPanelFooter>
</SidebarPanel>
);
Expand Down
57 changes: 51 additions & 6 deletions apps/web/test/sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ describe("Sidebar", () => {
expect(markup).toContain("data-ctx-account");
expect(markup).not.toContain(">Inbox<");
expect(markup).not.toContain('aria-label="Notifications"');
// Settings stays in the account menu, not a standalone footer icon.
expect(markup).not.toContain('aria-label="Settings"');
// Settings is its own direct control beside the account row (one
// click, not buried in the account menu).
expect(markup).toContain('aria-label="Settings"');
// Routines is first — CL-6362 gives it the same top-level rail slot
// as every other global surface.
expect(markup.indexOf(">Routines<")).toBeLessThan(
Expand Down Expand Up @@ -435,8 +436,13 @@ describe("Sidebar", () => {
//
// CL-6132: grown to the reference shape — the whole account row (avatar
// + name) is the trigger, and the menu itself carries a weekly usage
// line, Settings, a feedback link out to the repo's GitHub issues, a
// divider, and a danger-styled "Log out".
// line, a feedback link out to the repo's GitHub issues, a divider, and
// a danger-styled "Log out".
//
// A later pass split Settings out to its own direct icon beside the row
// (one click instead of two) — the menu still carries everything else
// that used to live alongside it, so nothing the old menu offered is
// stranded.
describe("the account menu", () => {
async function openAccountMenu(
container: HTMLDivElement,
Expand Down Expand Up @@ -494,17 +500,19 @@ describe("Sidebar", () => {
container.remove();
});

test("offers a Weekly usage line, Settings, a feedback link, and Log out", async () => {
test("offers a Weekly usage line, a feedback link, and Log out", async () => {
stubFetch();
const container = document.createElement("div");
document.body.appendChild(container);
const root = await openAccountMenu(container);

const menu = document.querySelector('[role="menu"]');
expect(menu?.textContent).toContain("Weekly usage");
expect(menu?.textContent).toContain("Settings");
expect(menu?.textContent).toContain("Send Feedback");
expect(menu?.textContent).toContain("Log out");
// Settings moved out to its own direct control (see the test
// below) — the menu no longer duplicates it.
expect(menu?.textContent).not.toContain("Settings");

const feedbackLink = menu?.querySelector<HTMLAnchorElement>(
'a[href*="github.com/corbitsdev/workbench"]',
Expand Down Expand Up @@ -542,4 +550,41 @@ describe("Sidebar", () => {
container.remove();
});
});

test("the settings icon navigates straight to Settings, no menu in the way", async () => {
stubFetch();
const container = document.createElement("div");
document.body.appendChild(container);
const navigated: string[] = [];
const root = createRoot(container);
await act(async () => {
root.render(
<TestQueryProvider>
<BenchProvider>
<Sidebar
path="/w"
user={user}
onNavigate={(to) => navigated.push(to)}
onSignOut={noop}
/>
</BenchProvider>
</TestQueryProvider>,
);
});

const settingsButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Settings"]',
);
expect(settingsButton).not.toBeNull();
await act(async () => {
settingsButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigated).toEqual(["/settings"]);
// No popup menu opened along the way — this is a direct control, not
// a trigger.
expect(document.querySelector('[role="menu"]')).toBeNull();

act(() => root.unmount());
container.remove();
});
});
81 changes: 81 additions & 0 deletions packages/chat-ui/src/avatar-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, test } from "bun:test";

import {
generatedAvatarStyle,
readableTextOn,
resolveAvatarFill,
} from "./avatar-identity";

describe("generatedAvatarStyle", () => {
test("is deterministic for the same principal", () => {
expect(generatedAvatarStyle("prn_alice")).toEqual(
generatedAvatarStyle("prn_alice"),
);
});

test("differs across distinct principals", () => {
const alice = generatedAvatarStyle("prn_alice");
const bob = generatedAvatarStyle("prn_bob");
expect(alice["--avatar-identity-bg"]).not.toBe(bob["--avatar-identity-bg"]);
});

test("never displays the seed itself", () => {
const style = generatedAvatarStyle("prn_super_secret_internal_id");
const values = Object.values(style).join(" ");
expect(values).not.toContain("prn_super_secret_internal_id");
});
});

describe("readableTextOn", () => {
test("picks a legible label color across the full hue range", () => {
// A spread of hand-picked HSL backgrounds spanning light and dark
// lightness at the generator's fixed saturation/lightness — every
// one of them must resolve to pure black or pure white, never a
// mid-tone that would read as washed out on either.
const seeds = [
"prn_a",
"prn_b",
"prn_c",
"prn_d",
"prn_e",
"prn_f",
"prn_g",
"prn_h",
];
for (const seed of seeds) {
const { "--avatar-identity-bg": bg, "--avatar-identity-fg": fg } =
generatedAvatarStyle(seed);
expect(["#000000", "#ffffff"]).toContain(fg);
expect(bg.startsWith("hsl(")).toBe(true);
}
});

test("is deterministic for the same background", () => {
const bg = "hsl(210 65% 45%)";
expect(readableTextOn(bg)).toBe(readableTextOn(bg));
});

test("falls back to a safe default for an unrecognized format", () => {
expect(readableTextOn("not-a-color")).toBe("#ffffff");
});
});

describe("resolveAvatarFill", () => {
test("a principal with no explicit image gets the generated fill", () => {
const fill = resolveAvatarFill("prn_alice");
expect(fill.kind).toBe("generated");
});

test("a principal with an explicit image still uses it", () => {
const fill = resolveAvatarFill("prn_alice", "https://example.com/a.png");
expect(fill).toEqual({
kind: "image",
url: "https://example.com/a.png",
});
});

test("an empty image string is treated as no image", () => {
const fill = resolveAvatarFill("prn_alice", "");
expect(fill.kind).toBe("generated");
});
});
Loading
Loading