fix(site): keep standard avatar border for normal AI spend state (#28037)

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.

<img width="604" height="542" alt="image"
src="https://github.com/user-attachments/assets/3dab5700-c35b-44a6-abc5-7413254adeee"
/>


---
*This PR was generated by Coder Agents on behalf of @tracyjohnsonux.*
This commit is contained in:
TJ
2026-08-13 07:20:40 -07:00
committed by GitHub
parent 2d9b6eda8f
commit 49cbd7dde3
2 changed files with 59 additions and 14 deletions
@@ -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 = {
@@ -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<UsageSeverity, string>;
// Elevated states show a corner badge with a distinct icon per state.
const severityIndicators: Partial<
Record<UsageSeverity, { badge: string; icon: JSX.Element; label: string }>
> = {
warning: {
badge: "bg-surface-orange text-highlight-orange",
icon: <TriangleAlertIcon aria-hidden className="size-3" />,
label: "AI spend is nearing its limit",
},
exceeded: {
badge: "bg-surface-red text-highlight-red",
icon: <OctagonAlertIcon aria-hidden className="size-3" />,
label: "AI spend limit exceeded",
},
};
interface UserDropdownProps {
user: TypesGen.User;
@@ -58,20 +70,28 @@ export const UserDropdown: FC<UserDropdownProps> = ({
spend && spend.spendLimit !== null
? getSeverity(spend.currentSpend, spend.spendLimit)
: "normal";
const indicator = spend ? severityIndicators[severity] : undefined;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-transparent border-0 cursor-pointer p-0"
aria-label={indicator ? `User menu. ${indicator.label}` : "User menu"}
className="relative bg-transparent border-0 cursor-pointer p-0"
>
<Avatar
fallback={user.username}
src={user.avatar_url}
size="lg"
className={spend ? severityBorderClasses[severity] : undefined}
/>
<Avatar fallback={user.username} src={user.avatar_url} size="lg" />
{indicator && (
<span
className={cn(
"absolute -top-2 -right-2 flex size-[18px] items-center",
"justify-center rounded",
indicator.badge,
)}
>
{indicator.icon}
</span>
)}
</button>
</DropdownMenuTrigger>