diff --git a/DESIGN.md b/DESIGN.md index ac25fbd84..33b543d2a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 @@ -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` / diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f44313b98..23979be23 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -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; diff --git a/apps/web/src/shell/sidebar.tsx b/apps/web/src/shell/sidebar.tsx index 5f2e2d166..00b41b860 100644 --- a/apps/web/src/shell/sidebar.tsx +++ b/apps/web/src/shell/sidebar.tsx @@ -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, @@ -248,43 +250,55 @@ export function Sidebar({ Evals - - - - - - - onNavigate(SETTINGS_PATH)}> - Settings - - - - Send Feedback - - - - - Log out - - - +
+ + + + + + + + + Send Feedback + + + + + Log out + + + + +
); diff --git a/apps/web/test/sidebar.test.tsx b/apps/web/test/sidebar.test.tsx index 5c3d499d8..e79b1a75a 100644 --- a/apps/web/test/sidebar.test.tsx +++ b/apps/web/test/sidebar.test.tsx @@ -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( @@ -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, @@ -494,7 +500,7 @@ 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); @@ -502,9 +508,11 @@ describe("Sidebar", () => { 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( 'a[href*="github.com/corbitsdev/workbench"]', @@ -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( + + + navigated.push(to)} + onSignOut={noop} + /> + + , + ); + }); + + const settingsButton = container.querySelector( + '[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(); + }); }); diff --git a/packages/chat-ui/src/avatar-identity.test.ts b/packages/chat-ui/src/avatar-identity.test.ts new file mode 100644 index 000000000..fa87492a3 --- /dev/null +++ b/packages/chat-ui/src/avatar-identity.test.ts @@ -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"); + }); +}); diff --git a/packages/chat-ui/src/avatar-identity.ts b/packages/chat-ui/src/avatar-identity.ts new file mode 100644 index 000000000..0968e0c9c --- /dev/null +++ b/packages/chat-ui/src/avatar-identity.ts @@ -0,0 +1,111 @@ +import { colorForPrincipal } from "@corbits/presence/color"; + +/** + * A person's generated fallback fill for react-ui's `Avatar`, which has no + * `style` prop — only `className` — because its own tone system is a + * closed enum reserved for agent identity (`AvatarTone`). These two CSS + * custom properties are meant to be set on an ancestor element (they + * inherit down the DOM to the `Avatar`'s own root span, which reads them + * back through the `avatar-identity-generated` class in `app.css`) rather + * than passed as a prop react-ui doesn't accept. + */ +export type GeneratedAvatarStyle = { + readonly "--avatar-identity-bg": string; + readonly "--avatar-identity-fg": string; +}; + +/** The className that reads `GeneratedAvatarStyle`'s custom properties + * back into an actual background/text pair. Apply to the `Avatar` itself + * (or the bespoke `.chat-presence-avatar` chip); the style values belong + * on an ancestor. */ +export const AVATAR_IDENTITY_CLASS = "avatar-identity-generated"; + +const HSL_PATTERN = + /^hsl\((\d+(?:\.\d+)?) (\d+(?:\.\d+)?)% (\d+(?:\.\d+)?)%\)$/; + +function hslToRgb( + h: number, + s: number, + l: number, +): readonly [number, number, number] { + const sat = s / 100; + const light = l / 100; + const k = (n: number) => (n + h / 30) % 12; + const a = sat * Math.min(light, 1 - light); + const f = (n: number) => + light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); + return [ + Math.round(f(0) * 255), + Math.round(f(8) * 255), + Math.round(f(4) * 255), + ]; +} + +function relativeLuminance(r: number, g: number, b: number): number { + const channel = (value: number) => { + const normalized = value / 255; + return normalized <= 0.03928 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); +} + +/** + * The legible initials color (pure black or pure white — never a + * mid-tone) for a `colorForPrincipal` background, chosen by WCAG + * relative luminance so a generated avatar stays readable regardless of + * which hue the hash lands on. `colorForPrincipal` fixes saturation and + * lightness but the hue swings the whole 0-360 range, so no single fixed + * text color clears contrast for every hash. + */ +export function readableTextOn(background: string): string { + const match = HSL_PATTERN.exec(background); + if (match === null) return "#ffffff"; + const [, h, s, l] = match; + const [r, g, b] = hslToRgb(Number(h), Number(s), Number(l)); + return relativeLuminance(r, g, b) > 0.4 ? "#000000" : "#ffffff"; +} + +/** + * A stable, per-principal fill for a human's fallback avatar. Reuses + * `@corbits/presence`'s `colorForPrincipal` — the app's one existing + * deterministic-identity-color function, already shipped for live + * cursors/presence dots (CL-6328) — rather than a second hashing scheme, + * paired with a computed readable text color. Never derived from render + * order or `Math.random()`: the same `principalId` always resolves to + * the same pair, in every surface, for every viewer. + */ +export function generatedAvatarStyle( + principalId: string, +): GeneratedAvatarStyle { + const background = colorForPrincipal(principalId); + return { + "--avatar-identity-bg": background, + "--avatar-identity-fg": readableTextOn(background), + }; +} + +export type AvatarFill = + | { readonly kind: "image"; readonly url: string } + | { readonly kind: "generated"; readonly style: GeneratedAvatarStyle }; + +/** + * Which fallback an avatar should render: an explicit image always wins + * over the generated fill, when one is on hand (e.g. `UserProfile.image` + * from better-auth) — the generated look is a fallback, not a + * replacement for a real picture. + */ +export function resolveAvatarFill( + principalId: string, + explicitImageUrl?: string | null, +): AvatarFill { + if ( + explicitImageUrl !== undefined && + explicitImageUrl !== null && + explicitImageUrl.length > 0 + ) { + return { kind: "image", url: explicitImageUrl }; + } + return { kind: "generated", style: generatedAvatarStyle(principalId) }; +} diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index a474de8de..57ed2543f 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -80,7 +80,7 @@ import { applyStreamReaction, useWorkbenchFeed, } from "./use-workbench-feed"; -import { colorForPrincipal } from "@corbits/presence/color"; +import { generatedAvatarStyle } from "./avatar-identity"; import { useWorkbenchPresenceRoster } from "./workbench-presence"; import { type } from "arktype"; import { @@ -120,14 +120,16 @@ export type TenantResolution = * One live presence entry for the workbench's who's-here stack (CL-6328) — * derived from this workbench's own `/stream` connection * (`useWorkbenchPresenceRoster`), never a second connection or an HTTP - * heartbeat poll. `displayName`/`color` are resolved client-side against - * the workbench's own participants and `@corbits/presence`'s deterministic - * `colorForPrincipal`, since the roster itself carries only ids. + * heartbeat poll. `displayName`/`color`/`textColor` are resolved + * client-side against the workbench's own participants and + * `generatedAvatarStyle`'s deterministic per-principal fill, since the + * roster itself carries only ids. */ export interface PresenceMember { readonly principalId: string; readonly displayName: string; readonly color: string; + readonly textColor: string; } /** One entry in the header's combined who's-active stack — an agent @@ -139,6 +141,7 @@ export interface TeamAvatarEntry { readonly label: string; readonly tone: "agent" | "neutral"; readonly color?: string; + readonly textColor?: string; } /** How many avatars the header shows before collapsing the rest into a @@ -170,6 +173,7 @@ export function buildTeamAvatarStack( label: member.displayName, tone: "neutral" as const, color: member.color, + textColor: member.textColor, })); return [...agents, ...humans]; } @@ -897,14 +901,18 @@ function ChatWorkspaceInner({ // `typingLabel` resolves a typing ping's principal. const presenceMembers: readonly PresenceMember[] = useMemo( () => - presenceRoster.map((member) => ({ - principalId: member.principalId, - displayName: typingLabel( - member.principalId, - activeWorkbench?.participants ?? [], - ), - color: colorForPrincipal(member.principalId), - })), + presenceRoster.map((member) => { + const style = generatedAvatarStyle(member.principalId); + return { + principalId: member.principalId, + displayName: typingLabel( + member.principalId, + activeWorkbench?.participants ?? [], + ), + color: style["--avatar-identity-bg"], + textColor: style["--avatar-identity-fg"], + }; + }), [presenceRoster, activeWorkbench?.participants], ); @@ -1121,7 +1129,10 @@ function ChatWorkspaceInner({ {entry.initials} diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx index f99cd57eb..87dcd37b1 100644 --- a/packages/chat-ui/src/composer.tsx +++ b/packages/chat-ui/src/composer.tsx @@ -15,8 +15,9 @@ import { useRef, useState, } from "react"; -import type { ChangeEvent, KeyboardEvent } from "react"; +import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react"; +import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity"; import type { Part, ParticipantRecord } from "./api"; import { activeMentionQuery, @@ -721,13 +722,24 @@ export const Composer = forwardRef< event.preventDefault(); pickMention(option); }} + style={ + isAgent + ? undefined + : (generatedAvatarStyle( + option.candidate.id, + ) as CSSProperties) + } > diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 47801afdf..a3b150472 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -13,6 +13,13 @@ export type { export { WorkbenchLoadingState } from "./loading-state"; +export { + AVATAR_IDENTITY_CLASS, + generatedAvatarStyle, + resolveAvatarFill, +} from "./avatar-identity"; +export type { AvatarFill, GeneratedAvatarStyle } from "./avatar-identity"; + export { PinnedStrip } from "./pinned-strip"; export { Composer, diff --git a/packages/chat-ui/src/pr-thread-view.tsx b/packages/chat-ui/src/pr-thread-view.tsx index 7efd2a37c..eda4be0b0 100644 --- a/packages/chat-ui/src/pr-thread-view.tsx +++ b/packages/chat-ui/src/pr-thread-view.tsx @@ -25,7 +25,9 @@ import { Avatar, Badge, Button } from "@corbits/react-ui"; import type { AvatarTone, BadgeTone } from "@corbits/react-ui"; import { Fragment } from "react"; +import type { CSSProperties } from "react"; +import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity"; import { Markdown } from "./markdown"; import { CHAT_STRINGS } from "./strings"; @@ -221,14 +223,25 @@ function SuggestedFixBlock({ fix }: { readonly fix: PrThreadSuggestedFix }) { } function ReplyRow({ reply }: { readonly reply: PrThreadReply }) { - const avatarTone: AvatarTone = reply.role === "human" ? "neutral" : "agent"; + const isHuman = reply.role === "human"; + const avatarTone: AvatarTone = isHuman ? "neutral" : "agent"; + // No stable reviewer id reaches this pure view (see the file header) — + // the reviewer's own display name is already the identity this row + // shows, so it doubles as the hash seed for a deterministic per-person + // fill (same reviewer, same color, on every reply and every reload). return ( -
+
diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 32efbd189..4e14777e7 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -832,6 +832,19 @@ border-radius: 50%; } +/* A human's generated fallback fill (`avatar-identity.ts`). react-ui's + `Avatar` takes no `style` prop — only `className` — so the two custom + properties this class reads are set on an ancestor by the caller and + inherit down through the DOM to whichever element carries this class. + Plain, unlayered CSS at equal specificity to react-ui's own + `bg-muted`/`text-muted-foreground` tone classes, positioned after them + in the cascade (this stylesheet imports after react-ui's), so it wins + without needing `!important`. */ +.avatar-identity-generated { + background-color: var(--avatar-identity-bg); + color: var(--avatar-identity-fg); +} + /* A SenderAvatar rendered without the profile-button wrapper (the streaming bubble) still occupies the same avatar column, so text never shifts when the streamed reply finalizes into a persisted diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 5b6873123..053582b09 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -25,6 +25,7 @@ import { toast, } from "@corbits/react-ui"; import { toReactUiReasoning } from "./agent-part-adapter"; +import { AVATAR_IDENTITY_CLASS, generatedAvatarStyle } from "./avatar-identity"; import { groupTimelineParts } from "./tool-activity"; import { ToolActivityGroup } from "./tool-activity-view"; import { @@ -37,6 +38,7 @@ import { PushPinSlash, Smiley, } from "@corbits/icons"; +import type { CSSProperties } from "react"; import { useEffect, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; @@ -269,6 +271,10 @@ type SenderDisplay = { readonly handle?: string; readonly isAgent: boolean; readonly initials: string; + /** The wire address behind this sender — never shown, only hashed + * (`generatedAvatarStyle`) into a stable per-person fallback color for + * a human's avatar. */ + readonly id: string; }; function senderDisplay( @@ -283,7 +289,12 @@ function senderDisplay( localPartOf(sender.address) === currentUser.principalId ) { const label = currentUser.name ?? CHAT_STRINGS.senderYou; - return { label, isAgent: false, initials: ownAvatarInitials(currentUser) }; + return { + label, + isAgent: false, + initials: ownAvatarInitials(currentUser), + id: currentUser.principalId, + }; } const matched = participants.find( @@ -307,6 +318,7 @@ function senderDisplay( handle: matched.handle, isAgent, initials: initialsOf(displayName ?? matched.handle), + id: matched.address, }; } @@ -315,6 +327,7 @@ function senderDisplay( label: sender.name, isAgent: false, initials: initialsOf(sender.name), + id: sender.address, }; } @@ -322,33 +335,53 @@ function senderDisplay( label: CHAT_STRINGS.senderFallbackMember, isAgent: false, initials: "?", + id: sender.address, }; } /** The message header's avatar chip — the same react-ui `Avatar` (tone by * agent-vs-neutral, a tooltip carrying the full name) `chat-workspace.tsx`'s - * member stack already uses, rather than a bespoke initials box. */ + * member stack already uses, rather than a bespoke initials box. A human + * sender additionally gets `generatedAvatarStyle`'s deterministic + * per-person fill — set on this wrap (Avatar takes no `style` prop) and + * inherited into `Avatar`'s own root span through the + * `AVATAR_IDENTITY_CLASS` className — so every human reads as their own + * color instead of the same flat neutral gray agents already stand apart + * from. */ function SenderAvatar({ + id, initials, label, isAgent, tenantMonogram, tenantName, }: { + id: string; initials: string; label: string; isAgent: boolean; tenantMonogram?: string; tenantName?: string; }) { + const identityStyle = isAgent + ? undefined + : (generatedAvatarStyle(id) as CSSProperties); return ( - + {tenantMonogram !== undefined ? ( {display !== undefined && ( {(account) => ( )} @@ -106,21 +112,43 @@ async function copyEmail(email: string): Promise { * `BenchSectionView` is: directly renderable in tests without a fetch stub. */ export function AccountSectionView({ + id, name, email, emailVerified, + image, onSignOut, }: { + readonly id: string; readonly name: string; readonly email: string; readonly emailVerified: boolean; + readonly image?: string; readonly onSignOut?: () => void; }) { + const fill = resolveAvatarFill(id, image); return (
- + {fill.kind === "image" ? ( + {name} + ) : ( + + + + )}
{name} diff --git a/packages/settings-ui/src/styles.css b/packages/settings-ui/src/styles.css index a74bc999b..9a82de48b 100644 --- a/packages/settings-ui/src/styles.css +++ b/packages/settings-ui/src/styles.css @@ -703,6 +703,18 @@ min-width: 0; } +/* An account's real profile picture (e.g. better-auth's `image`), shown + instead of the generated initials fallback whenever one is on hand — + same footprint as `Avatar`'s own `lg` size so the row doesn't reflow + depending on which a given account has. */ +.settings-account-avatar-image { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + object-fit: cover; +} + .settings-account-identity-text { display: flex; flex-direction: column; diff --git a/packages/settings-ui/test/account-section.test.tsx b/packages/settings-ui/test/account-section.test.tsx index 180686994..9e24aa145 100644 --- a/packages/settings-ui/test/account-section.test.tsx +++ b/packages/settings-ui/test/account-section.test.tsx @@ -40,6 +40,7 @@ describe("AccountSectionView", () => { test("renders no Sign out action when the host gives no onSignOut", () => { const el = mount( { let signedOut = false; const el = mount( { test("shows an avatar and the name/email in the account card, and the same email again in the quieter details subsection", () => { const el = mount( { try { const el = mount( { test("AccountSectionView renders only the user's name and email, never a uuid", () => { const markup = renderToStaticMarkup( { const report = auditUiVocabulary([ @@ -381,3 +385,35 @@ test("stripNonUserFacing preserves line and column positions", () => { expect(stripped).not.toContain("hub"); expect(stripped).toContain("workbench"); }); + +test("a className built from a literal plus an interpolation is not copy", () => { + expect( + findViolations([ + { + relPath: "packages/chat-ui/src/timeline.tsx", + contents: "const c = `chat-sender-avatar ${AVATAR_IDENTITY_CLASS}`;", + }, + ]), + ).toEqual([]); +}); + +test("a key with no real whitespace is not copy, even after blanking", () => { + expect( + findViolations([ + { + relPath: "apps/web/src/pages/mission-control-page.tsx", + contents: "const k = `bench:${bench.id}`;", + }, + ]), + ).toEqual([]); +}); + +test("real copy containing a banned term is still caught", () => { + const found = findViolations([ + { + relPath: "apps/web/src/x.tsx", + contents: 'const s = "Open the chat to keep going.";', + }, + ]); + expect(found.length).toBe(1); +}); diff --git a/scripts/checks/ui-vocabulary.ts b/scripts/checks/ui-vocabulary.ts index 291b3fc05..444a80fdb 100644 --- a/scripts/checks/ui-vocabulary.ts +++ b/scripts/checks/ui-vocabulary.ts @@ -156,12 +156,29 @@ function stripInterpolations(inner: string): string { * tokens), an arktype/TS quoted-union type literal ('personal' | * 'bench'), or a URL/API path. None of those are copy a user reads. */ function isProseLiteral(literal: string): boolean { - const inner = literal.slice(1, -1); - if (!/\s/.test(inner)) return false; + const raw = literal.slice(1, -1); + // Whitespace is judged on the literal as written: blanking an + // interpolation substitutes spaces, which would make a key like + // `` `bench:${id}` `` — no real whitespace, never prose — look like it. + if (!/\s/.test(raw)) return false; + // Shape, though, is judged with interpolations blanked, since they are + // code the user never sees: otherwise a className like `` `a-b ${X}` `` + // tokenizes as ["a-b", "${X}"] and the class-list exemption never applies. + const inner = stripInterpolations(raw); if (QUOTED_UNION.test(inner.trim())) return false; if (inner.trim().startsWith("/")) return false; const tokens = inner.trim().split(/\s+/); - if (tokens.length > 1 && tokens.every((token) => KEBAB_TOKEN.test(token))) { + // A className built from a literal plus an interpolation — `` `a-b ${X}` `` + // — blanks down to one token, so the multi-token rule alone would read it + // as prose. A single token only counts as a class when it is hyphenated: + // a bare lowercase word like "workbench" is exactly the copy this check + // exists to catch. + const everyTokenIsClassLike = tokens.every((token) => + KEBAB_TOKEN.test(token), + ); + const looksLikeClassList = + tokens.length > 1 || (tokens[0] ?? "").includes("-"); + if (everyTokenIsClassLike && looksLikeClassList) { return false; } return true;