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
17 changes: 15 additions & 2 deletions scripts/design-system/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,10 @@ const BOUNDARY_ROLES = [
"--border-danger",
"--border-warning",
"--status-online",
"--status-away",
// Avatar cores are inset inside a same-status outline. The outline, not
// the core, touches the surrounding surface (WCAG 1.4.11 graphic boundary).
"--status-avatar-online-border",
"--status-avatar-away-border",
"--status-offline",
];

Expand Down Expand Up @@ -253,7 +256,17 @@ for (const [mode, map] of Object.entries(modes)) {
for (const [role, fill] of PAIRS) check(role, fill);
for (const [text, tint] of TINT_PAIRS) check(text, tint);
for (const role of BOUNDARY_ROLES) {
for (const surface of BOUNDARY_SURFACES) {
const surfaces = role.startsWith("--status-avatar-")
? [
...BOUNDARY_SURFACES,
"--affordance-selected",
"--affordance-panel-hover",
"--affordance-subtle-hover",
"--affordance-floating-hover",
"--neutral-4",
]
: BOUNDARY_SURFACES;
for (const surface of surfaces) {
const borderColor = resolve(map, role);
const surfaceColor = resolve(map, surface);
const ratio =
Expand Down
6 changes: 6 additions & 0 deletions src/shared/design-system/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ clipped artwork, and avatars without a badge retain their full shape. The agent
cutout and badge follow the squircle contour; agent badges use live presence and leave
unknown status unbadged. All three statuses use solid fills: green for Online,
yellow for Away, and grey for Offline, with semantic light and dark values.
Online and Away retain step-10 centers inside a 1px inset, same-hue step-11
outline. The outline supplies the 3:1 non-text boundary on panel, popup,
selected and hover surfaces; measure that rendered boundary, not the inset
center against a surface it does not touch. Offline stays unoutlined. The
badge footprint and Bézier artwork cutout are unchanged. This establishes
boundary contrast, not a blanket WCAG conformance claim for presence.

## Public identity text

Expand Down
21 changes: 19 additions & 2 deletions src/shared/design-system/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,18 +349,35 @@
aspect-ratio: 1;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--status-online);
--avatar-status-fill: var(--status-avatar-online);
background: var(--status-avatar-online-border);
pointer-events: none;
}

/* A same-hue outline carries the status boundary while preserving the
requested step-10 center. Inheriting the mask also outlines squircles. */
.buzz-avatar-status-dot::after {
content: "";
position: absolute;
inset: 1px;
border-radius: inherit;
mask: inherit;
background: var(--avatar-status-fill);
}

.buzz-avatar-status[data-status="away"] .buzz-avatar-status-dot {
background: var(--status-away);
--avatar-status-fill: var(--status-away);
background: var(--status-avatar-away-border);
}

.buzz-avatar-status[data-status="offline"] .buzz-avatar-status-dot {
background: var(--status-offline);
}

.buzz-avatar-status[data-status="offline"] .buzz-avatar-status-dot::after {
content: none;
}

/* A panel is the default opaque reading surface over the atmospheric
backdrop. Its border and threshold shadow arrive together: taking only
bg-panel would lose the boundary that lets it sit above the gradient. */
Expand Down
10 changes: 8 additions & 2 deletions src/shared/design-system/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,10 @@
--text-warning: var(--amber-12);
--text-success: var(--green-12);
--status-online: var(--green-11);
--status-away: var(--amber-11);
--status-avatar-online: var(--green-10);
--status-avatar-online-border: var(--green-11);
--status-away: var(--amber-10);
--status-avatar-away-border: var(--amber-11);
--status-offline: var(--neutral-8);
--text-accent: var(--purple-12);
--text-link: var(--blue-12);
Expand Down Expand Up @@ -556,7 +559,10 @@
--text-warning: var(--amber-12);
--text-success: var(--green-12);
--status-online: var(--green-11);
--status-away: var(--amber-11);
--status-avatar-online: var(--green-10);
--status-avatar-online-border: var(--green-11);
--status-away: var(--amber-10);
--status-avatar-away-border: var(--amber-11);
--status-offline: var(--neutral-10);
--text-accent: var(--purple-12);
--text-link: var(--blue-12);
Expand Down
22 changes: 21 additions & 1 deletion src/shared/design-system/tokens/contrast.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ describe("semantic contrast contract", () => {

it.each([
["online", "green"],
["away", "amber"],
["avatar-online-border", "green"],
["avatar-away-border", "amber"],
["offline", "neutral"],
])("checks %s status on supported surfaces in both modes", (role, hue) => {
const result = check(
Expand All @@ -94,6 +95,25 @@ describe("semantic contrast contract", () => {
);
});

it.each([
["online", "green"],
["away", "amber"],
])(
"rejects a %s outline that disappears on hover or selection",
(role, hue) => {
const result = check(
tokens.replaceAll(
`--status-avatar-${role}-border: var(--${hue}-11);`,
`--status-avatar-${role}-border: var(--${hue}-10);`,
),
);
expect(result.status, result.output).toBe(1);
expect(result.output).toContain(
`light: --status-avatar-${role}-border on --affordance-selected`,
);
},
);

it.each(["warning", "success", "accent"])(
"checks %s text against its semantic fill, not only the palette",
(role) => {
Expand Down
25 changes: 23 additions & 2 deletions src/shared/design-system/tokens/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,35 @@ export const ROLE_GROUPS: RoleGroup[] = [
token: "status-online",
variable: "--status-online",
pointsAt: "green-11 light / green-11 dark",
use: "Online presence dot.",
use: "Running activity status fill.",
status: "core",
},
{
token: "status-avatar-online",
variable: "--status-avatar-online",
pointsAt: "green-10 light / green-10 dark",
use: "Online avatar badge center, paired with status-avatar-online-border.",
status: "core",
},
{
token: "status-away",
variable: "--status-away",
pointsAt: "amber-10 light / amber-10 dark",
use: "Away avatar badge center, paired with status-avatar-away-border.",
status: "core",
},
{
token: "status-avatar-online-border",
variable: "--status-avatar-online-border",
pointsAt: "green-11 light / green-11 dark",
use: "Online avatar outline; provides 3:1 against adjacent surfaces.",
status: "core",
},
{
token: "status-avatar-away-border",
variable: "--status-avatar-away-border",
pointsAt: "amber-11 light / amber-11 dark",
use: "Away presence dot.",
use: "Away avatar outline; provides 3:1 against adjacent surfaces.",
status: "core",
},
{
Expand Down
25 changes: 25 additions & 0 deletions tests/fixtures/design-system/status-colors-preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Avatar badge colors</title>
<style>
body { margin: 0; padding: 24px; background: var(--surface-inset); color: var(--text-standard); }
main { max-width: 1120px; margin: auto; }
h1 { font-size: 28px; margin: 0 0 8px; }
h2 { font-size: 18px; margin: 0 0 16px; }
p { margin: 8px 0; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
.panel { padding: 12px; border-radius: 16px; background: var(--surface-panel); color: var(--text-standard); }
.panel.before { --status-away: var(--amber-11); --status-avatar-online: var(--green-11); }
.avatar-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 16px; }
.sizes { display: flex; align-items: center; gap: 4px; }
.panel.before .buzz-avatar-status-dot::after { content: none; }
.example { display: grid; justify-items: center; gap: 8px; }
.note { font-size: 14px; }
@media (max-width: 650px) { body { padding: 16px; } .comparison { grid-template-columns: 1fr; } }
</style>
</head>
<body><div id="root"></div><script type="module" src="./status-colors-preview.tsx"></script></body>
</html>
68 changes: 68 additions & 0 deletions tests/fixtures/design-system/status-colors-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createRoot } from "react-dom/client";
import "@fontsource-variable/inter/wght.css";
import "../../../src/shared/design-system/styles/globals.css";
import { Avatar } from "../../../src/shared/design-system/ui/Avatar";

function Preview() {
return (
<main>
<h1>Avatar badge colors</h1>
<p>
Online and Away use step-10 centers with a 1px step-11 outline. Offline,
text colors, avatar sizes, and the Bézier notch stay unchanged.
</p>
<div className="comparison">
{(["light", "dark"] as const).flatMap((mode) =>
[true, false].map((before) => (
<section
key={`${mode}-${before}`}
className={`panel ${mode} ${before ? "before" : ""}`}
>
<h2>
{mode === "light" ? "Light" : "Dark"} ·{" "}
{before ? "baseline · step 11" : "step 10 + outline"}
</h2>
{(["circle", "squircle"] as const).map((shape) => (
<div className="avatar-row" key={shape}>
{(["online", "away", "offline"] as const).map((status) => (
<div className="example" key={status}>
<div className="sizes">
{(status === "offline"
? (["large"] as const)
: (["small", "default", "large"] as const)
).map((size) => (
<Avatar
key={size}
alt={shape === "circle" ? "Person" : "Agent"}
fallback={shape === "circle" ? "P" : "A"}
size={size}
shape={shape}
statusBadge={status}
/>
))}
</div>
<span>{status}</span>
</div>
))}
</div>
))}
</section>
)),
)}
</div>
<p className="note">
Real Avatar components at 24, 32, and 40px, in both shapes and themes.
The outline is the visible status boundary: its lowest measured contrast
is 3.30:1 on supported opaque surfaces, including selection and hover.
The step-10 centers alone do not meet 3:1 on every surface.
</p>
<p className="note">
“Baseline” reproduces Buzz 2.0’s unchanged step-11 badges. It is not a
screenshot of the installed legacy Buzz app.
</p>
</main>
);
}

const root = document.getElementById("root");
if (root) createRoot(root).render(<Preview />);
42 changes: 42 additions & 0 deletions tests/fixtures/design-system/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,44 @@ test("status badges keep avatar sizes and show a clear cutout in both modes", as
await expect(dot).toHaveCSS("background-image", "none");
await expect(dot).toHaveCSS("box-shadow", "none");
}
// The same-status outline is what meets the surrounding surface. Check
// the actual CSS paint stack in the browser, including squircle masks.
for (const status of ["online", "away", "offline"]) {
const dots = page.locator(
`.buzz-avatar-status[data-status="${status}"] .buzz-avatar-status-dot`,
);
const centers = await dots.evaluateAll((elements) =>
elements.map((element) => {
const outer = getComputedStyle(element);
const inner = getComputedStyle(element, "::after");
return {
content: inner.content,
inset: inner.inset,
color: inner.backgroundColor,
mask: inner.maskImage,
outerMask: outer.maskImage,
};
}),
);
for (const center of centers) {
if (status === "offline") {
expect(center.content).toBe("none");
} else {
expect(center.content).toBe('""');
expect(center.inset).toBe("1px");
expect(center.mask).toBe(center.outerMask);
expect(center.color).toBe(
status === "online"
? mode === "light"
? "rgb(43, 154, 102)"
: "rgb(51, 176, 116)"
: mode === "light"
? "rgb(255, 186, 24)"
: "rgb(255, 214, 10)",
);
}
}
}
const box = await large.boundingBox();
if (!box) throw new Error("Large status avatar is not visible");
const clip = {
Expand Down Expand Up @@ -128,6 +166,10 @@ test("status badges keep avatar sizes and show a clear cutout in both modes", as
expect(difference(paint.gap, paint.background), `${mode} gap`).toBeLessThan(
12,
);
expect(
difference(paint.dot, mode === "light" ? [43, 154, 102] : [51, 176, 116]),
`${mode} rendered step-10 center`,
).toBeLessThan(3);
expect(
difference(paint.dot, paint.background),
`${mode} dot`,
Expand Down
Loading