mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user