From 49cbd7dde3d7f5667ea272d58af0c095b9e0aaf8 Mon Sep 17 00:00:00 2001 From: TJ Date: Thu, 13 Aug 2026 07:20:40 -0700 Subject: [PATCH] fix(site): keep standard avatar border for normal AI spend state (#28037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navbar avatar received a grey `border-content-secondary` override whenever AI spend data was present, so the default (normal spend) state looked different from a standard avatar. It also relied entirely on border color to communicate state, which is easy to miss and inaccessible to colorblind users. ## Changes - **Avatar border**: always standard — the severity border override is removed for all states. - **Warning / exceeded states** get a notification-style corner badge (like the inbox unread badge): - Warning: triangle-alert icon on the orange alert surface (`surface-orange` / `highlight-orange`). - Exceeded: octagon-alert icon on the red alert surface (`surface-red` / `highlight-red`). - Distinct icon shapes per state make the change perceivable without relying on color; surface/highlight token pairs keep the icon light on dark mode and dark on light mode. - **Accessibility**: the trigger now has a descriptive accessible name — `User menu`, `User menu. AI spend is nearing its limit`, or `User menu. AI spend limit exceeded`. Storybook: `AvatarBorderNormal/Warning/Exceeded` stories assert the trigger's accessible name per state, and Chromatic snapshots cover the visual states. image --- *This PR was generated by Coder Agents on behalf of @tracyjohnsonux.* --- .../UserDropdown/UserDropdown.stories.tsx | 27 ++++++++++- .../Navbar/UserDropdown/UserDropdown.tsx | 46 +++++++++++++------ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 58c518a998..8350304389 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -225,7 +225,8 @@ export const AISpendZeroLimit: Story = { }, }; -// Dropdown closed to isolate the avatar border, which reflects spend severity. +// Dropdown closed to isolate the avatar and its severity badge, which +// indicates AI spend limit severity. export const AvatarBorderDisabled: Story = { parameters: { @@ -238,6 +239,14 @@ export const AvatarBorderNormal: Story = { ...aiCostControl, queries: [{ key: meAISpendKey, data: mockAISpend }], }, + play: async ({ canvasElement, step }) => { + await step("shows no severity indicator for normal spend", async () => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "User menu" }), + ).toBeInTheDocument(); + }); + }, }; export const AvatarBorderWarning: Story = { @@ -250,6 +259,14 @@ export const AvatarBorderWarning: Story = { }, ], }, + play: async ({ canvasElement, step }) => { + await step("labels the trigger with the warning state", async () => { + const canvas = within(canvasElement); + await canvas.findByRole("button", { + name: "User menu. AI spend is nearing its limit", + }); + }); + }, }; export const AvatarBorderExceeded: Story = { @@ -262,6 +279,14 @@ export const AvatarBorderExceeded: Story = { }, ], }, + play: async ({ canvasElement, step }) => { + await step("labels the trigger with the exceeded state", async () => { + const canvas = within(canvasElement); + await canvas.findByRole("button", { + name: "User menu. AI spend limit exceeded", + }); + }); + }, }; export const AISpendHiddenOnInvalidData: Story = { diff --git a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx index 36d08b3284..3a9f52a5c7 100644 --- a/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/modules/dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -1,4 +1,5 @@ -import type { FC } from "react"; +import { OctagonAlertIcon, TriangleAlertIcon } from "lucide-react"; +import type { FC, JSX } from "react"; import { useQuery } from "react-query"; import { meAISpend } from "#/api/queries/users"; import type * as TypesGen from "#/api/typesGenerated"; @@ -11,14 +12,25 @@ import { } from "#/components/DropdownMenu/DropdownMenu"; import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility"; import { getSeverity, type UsageSeverity } from "#/utils/budget"; +import { cn } from "#/utils/cn"; import { UserDropdownAISpend } from "./UserDropdownAISpend"; import { UserDropdownContent } from "./UserDropdownContent"; -const severityBorderClasses = { - normal: "border-content-secondary", - warning: "border-content-warning", - exceeded: "border-content-destructive", -} as const satisfies Record; +// Elevated states show a corner badge with a distinct icon per state. +const severityIndicators: Partial< + Record +> = { + warning: { + badge: "bg-surface-orange text-highlight-orange", + icon: , + label: "AI spend is nearing its limit", + }, + exceeded: { + badge: "bg-surface-red text-highlight-red", + icon: , + label: "AI spend limit exceeded", + }, +}; interface UserDropdownProps { user: TypesGen.User; @@ -58,20 +70,28 @@ export const UserDropdown: FC = ({ spend && spend.spendLimit !== null ? getSeverity(spend.currentSpend, spend.spendLimit) : "normal"; + const indicator = spend ? severityIndicators[severity] : undefined; return (