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
2 changes: 1 addition & 1 deletion src/app/shell/ProfileButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ it("keeps the header cutout and menu status in sync with presence", () => {
/>,
);
const button = screen.getByRole("button", { name: "Your profile" });
expect(button).toHaveAttribute("data-icon-variant", "chrome");
expect(button).toHaveAttribute("data-icon-variant", "avatar");
expect(button.querySelector(".buzz-avatar svg")).toBeInTheDocument();
expect(button.querySelector(".buzz-avatar")).not.toHaveTextContent("?");
expect(button.querySelector(".buzz-avatar-status")).toHaveAttribute(
Expand Down
2 changes: 1 addition & 1 deletion src/app/shell/ProfileButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function ProfileButton({
aria-label="Your profile"
ref={profileTrigger}
title={name}
variant="chrome"
variant="avatar"
Comment thread
klopez4212 marked this conversation as resolved.
shape="round"
icon={
<span
Expand Down
3 changes: 3 additions & 0 deletions src/shared/design-system/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ Avatar `shape="circle"` or `shape="squircle"`; the shape carries identity meanin
not density or emphasis. The caller supplies identity type from domain data,
never a name or picture heuristic. `size="fill"` fills the owning layout’s
available space. Shape clips the artwork, never the interactive focus target.
Avatar-only controls use `IconButton variant="avatar"` so the surrounding backdrop
shows through their cutouts at rest, hover, press, and while a menu is open. The
button retains its unmasked keyboard focus ring.
Circular and squircle avatars can add `statusBadge="online" | "away" | "offline"`. The dot
uses a semantic green, yellow, or grey role with light and dark values. Its inset
cutout and dot scale with the existing avatar size; the dot is separate from the
Expand Down
14 changes: 11 additions & 3 deletions src/shared/design-system/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
text-underline-offset: var(--space-1);
}

/* Only pointer devices get hover; keyboard focus remains the host's signal. */
/* Avatar controls keep the backdrop visible through their artwork cutouts.
Only pointer devices get hover; keyboard focus remains the host's signal. */
@media (hover: hover) and (pointer: fine) {
.buzz-button[data-variant="prominent"]:hover:not([data-disabled]) {
background: var(--affordance-prominent-hover);
Expand All @@ -94,7 +95,11 @@
[data-variant="outline"],
[data-variant="ghost"],
[data-variant="inverted"]
):hover:not([data-disabled], [data-icon-variant="chrome"]) {
):hover:not(
[data-disabled],
[data-icon-variant="chrome"],
[data-icon-variant="avatar"]
) {
background: var(--affordance-subtle-hover);
}
.buzz-button[data-variant="destructive"]:hover:not([data-disabled]) {
Expand All @@ -115,7 +120,10 @@
[data-variant="outline"],
[data-variant="ghost"],
[data-variant="inverted"]
):is(:active, [aria-expanded="true"]):not([data-disabled]) {
):is(:active, [aria-expanded="true"]):not(
[data-disabled],
[data-icon-variant="avatar"]
) {
background: var(--affordance-subtle-pressed);
}
.buzz-button[data-variant="destructive"]:is(
Expand Down
5 changes: 3 additions & 2 deletions src/shared/design-system/ui/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ type IconButtonVariant =
| NonNullable<ButtonProps["variant"]>
| "solid"
| "tint"
| "chrome";
| "chrome"
| "avatar";
Comment thread
klopez4212 marked this conversation as resolved.
type IconButtonSize =
| NonNullable<ButtonProps["size"]>
| "xs"
Expand Down Expand Up @@ -37,7 +38,7 @@ export function IconButton({
variant={
variant === "solid"
? "prominent"
: variant === "tint" || variant === "chrome"
: variant === "tint" || variant === "chrome" || variant === "avatar"
? "ghost"
: variant
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/design-system/ui/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"link",
"tint",
"chrome",
"avatar",
"shape: round (default) | control",
"size: xs (20px, 12px icon) | sm (32px) | md (40px) | lg (52px)",
"legacy aliases: quiet, solid, compact, toolbar, default, large",
Expand Down
4 changes: 2 additions & 2 deletions src/shared/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
}

.shell-header {
@apply flex h-14 shrink-0 items-center justify-between gap-3 px-4;
@apply flex h-14 shrink-0 items-center justify-between gap-3 pl-4 pr-2 sm:pr-4;
}
.shell-communities {
@apply flex min-w-0 items-center justify-start;
Expand Down Expand Up @@ -231,7 +231,7 @@
}
@media (max-width: 700px) {
.shell-header {
@apply gap-2 px-2;
@apply gap-2 pl-2;
}
}
@media (max-width: 650px) {
Expand Down
13 changes: 5 additions & 8 deletions tests/browser/presence.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ test("presence becomes usable during held HTTP work and unfinished subscription

// Real account controls -> shared activity -> retained session -> production
// broker -> authenticated socket; real localStorage survives app reconstruction.
test("profile trigger retains shared hover, press, and open feedback", async ({
test("profile trigger stays transparent through hover, press, and open menu", async ({
page,
app,
}) => {
Expand All @@ -428,21 +428,18 @@ test("profile trigger retains shared hover, press, and open feedback", async ({
name: "Your profile",
exact: true,
});
for (const [mode, hover, pressed] of [
["light", "rgba(255, 255, 255, 0.62)", "rgb(218, 218, 218)"],
["dark", "rgba(28, 28, 28, 0.66)", "rgb(89, 89, 89)"],
]) {
for (const mode of ["light", "dark"]) {
await page.evaluate((value) => {
document.documentElement.dataset.colorMode = value;
}, mode);
await trigger.hover();
await expect(trigger).toHaveCSS("background-color", hover);
await expect(trigger).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.mouse.down();
await expect(trigger).toHaveCSS("background-color", pressed);
await expect(trigger).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.mouse.up();
await page.mouse.move(1, 1);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await expect(trigger).toHaveCSS("background-color", pressed);
await expect(trigger).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await page.keyboard.press("Escape");
await expect(trigger).toHaveAttribute("aria-expanded", "false");
}
Expand Down
129 changes: 129 additions & 0 deletions tests/browser/profile-avatar-gap.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { test, expect } from "./fixture.mjs";

// Real CSS paint and shell geometry require a browser, not DOM emulation.
test("profile avatar cutout shows the shell through hover, press and open menu", async ({
page,
app,
browserName,
}, testInfo) => {
await page.goto(app.origin);
const control = page.getByRole("button", {
name: "Your profile",
exact: true,
includeHidden: true,
});
await expect(control.locator(".buzz-avatar-status")).toHaveAttribute(
"data-status",
"online",
);
await control.click();
await page.getByRole("menuitem", { name: "Settings", exact: true }).click();
await expect(page.getByRole("main")).toBeFocused();
for (const width of [390, 639, 640, 650, 700, 701, 1280]) {
await page.setViewportSize({ width, height: 844 });
const avatar = await control.boundingBox();
const panel = await page
.locator('[aria-labelledby="settings-title"]')
.boundingBox();
expect(avatar.width).toBe(40);
expect
.soft(avatar.x + avatar.width, `${width}px: avatar and card right edges`)
.toBe(panel.x + panel.width);
}
await page.setViewportSize({ width: 1280, height: 844 });
for (const mode of ["light", "dark"]) {
await page.emulateMedia({ colorScheme: mode });
await expect(page.locator("html")).toHaveAttribute("data-color-mode", mode);
// Return to an ordinary pointer state after the preceding keyboard check.
await page.mouse.click(400, 20);
await page.mouse.move(0, 0);
const bounds = await control.boundingBox();
const clip = {
x: bounds.x - 4,
y: bounds.y - 4,
width: bounds.width + 8,
height: bounds.height + 8,
};
await control.evaluate((el) => {
el.style.visibility = "hidden";
});
let background;
try {
background = await page.screenshot({ clip });
} finally {
await control.evaluate((el) => {
el.style.removeProperty("visibility");
});
}
const sample = (png) =>
page.evaluate(async (base64) => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
const at = (x, y) => [...ctx.getImageData(x, y, 1, 1).data];
return {
// The screenshot includes 4px outside the 40px control. Its gap
// is at avatar-local (29,29); the other samples detect a focus ring.
gap: at(33, 33),
left: at(1, 24),
top: at(24, 1),
right: at(46, 24),
};
}, png.toString("base64"));
const expected = await sample(background);
for (const state of ["rest", "hover", "pressed", "open"]) {
if (state === "hover") await control.hover();
if (state === "pressed") await page.mouse.down();
if (state === "open") {
await expect(control).toHaveAttribute("aria-expanded", "true");
}
try {
await expect(control).toHaveCSS("outline-style", "none");
await expect(control).toHaveCSS("border-width", "0px");
await expect(control).toHaveCSS("box-shadow", "none");
const screenshot = await page.screenshot({
clip,
path: testInfo.outputPath(`${mode}-${state}.png`),
});
expect
.soft(
await sample(screenshot),
`${mode}/${state}: clear cutout and no pointer ring`,
)
.toEqual(expected);
await page.screenshot({
path: testInfo.outputPath(`${mode}-${state}-shell.png`),
});
} finally {
if (state === "pressed") await page.mouse.up();
}
}
await page.keyboard.press("Escape");
await expect(control).toBeFocused();
const tab =
browserName === "webkit" && process.platform === "darwin"
? "Alt+Tab"
: "Tab";
await page.keyboard.press(`Shift+${tab}`);
await page.keyboard.press(tab);
await expect(control).toBeFocused();
await expect(control).toHaveCSS("outline-style", "solid");
await expect(control).toHaveCSS("outline-width", "2px");
await page.screenshot({
path: testInfo.outputPath(`${mode}-keyboard-focus.png`),
});
await control.press("Enter");
await expect(control).toHaveAttribute("aria-expanded", "true");
await page.keyboard.press("Escape");
await expect(control).toHaveCSS("mask-image", "none");
await page.mouse.click(400, 20);
await expect(control).not.toBeFocused();
await expect(control).toHaveCSS("outline-style", "none");
await page.screenshot({ path: testInfo.outputPath(`${mode}-shell.png`) });
}
});
60 changes: 60 additions & 0 deletions tests/fixtures/design-system/ui/ButtonSpecimens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import {
PlusIcon,
GearIcon,
} from "../../../../src/shared/design-system/icons";
import { Avatar } from "../../../../src/shared/design-system/ui/Avatar";
import {
MenuRoot,
MenuTrigger,
MenuPopup,
MenuItem,
} from "../../../../src/shared/design-system/ui/Menu";
import { Button } from "../../../../src/shared/design-system/ui/Button";
import { IconButton } from "../../../../src/shared/design-system/ui/IconButton";

Expand Down Expand Up @@ -232,6 +239,59 @@ export function IconButtonSpecimen() {
</div>
</div>
</section>
<section
className="component-specimen-group"
aria-label="Avatar icon treatments"
>
<h2 className="text-body-sm text-tertiary">
Avatar · Transparent cutouts
</h2>
<p className="text-body-sm text-subtle">
Hover, press, or open the menu: the backdrop stays visible through the
avatar cutout. Tab to the enabled control to inspect keyboard focus.
</p>
<div
className="component-specimen-frame"
style={{ background: "var(--bg-app)" }}
>
<div className="component-specimen-row">
<MenuRoot>
<MenuTrigger
render={
<IconButton
variant="avatar"
aria-label="Avatar profile menu"
icon={
<Avatar
alt="Alex Morgan"
fallback="A"
size="fill"
statusBadge="online"
/>
}
/>
}
/>
<MenuPopup size="compact">
<MenuItem>View profile</MenuItem>
</MenuPopup>
</MenuRoot>
<IconButton
variant="avatar"
disabled
aria-label="Disabled avatar profile"
icon={
<Avatar
alt="Sam Rivera"
fallback="S"
size="fill"
statusBadge="away"
/>
}
/>
</div>
</div>
</section>
<section
className="component-specimen-group"
aria-label="Chrome icon treatments"
Expand Down
Loading