diff --git a/src/app/shell/ProfileButton.test.tsx b/src/app/shell/ProfileButton.test.tsx index 15bc2c505..43a9ed923 100644 --- a/src/app/shell/ProfileButton.test.tsx +++ b/src/app/shell/ProfileButton.test.tsx @@ -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( diff --git a/src/app/shell/ProfileButton.tsx b/src/app/shell/ProfileButton.tsx index 80b2186c3..e8ca69d76 100644 --- a/src/app/shell/ProfileButton.tsx +++ b/src/app/shell/ProfileButton.tsx @@ -175,7 +175,7 @@ export function ProfileButton({ aria-label="Your profile" ref={profileTrigger} title={name} - variant="chrome" + variant="avatar" shape="round" icon={ | "solid" | "tint" - | "chrome"; + | "chrome" + | "avatar"; type IconButtonSize = | NonNullable | "xs" @@ -37,7 +38,7 @@ export function IconButton({ variant={ variant === "solid" ? "prominent" - : variant === "tint" || variant === "chrome" + : variant === "tint" || variant === "chrome" || variant === "avatar" ? "ghost" : variant } diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts index 68ba573c9..198d153a2 100644 --- a/src/shared/design-system/ui/registry.ts +++ b/src/shared/design-system/ui/registry.ts @@ -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", diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index fcad856ce..3a9514542 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -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; @@ -231,7 +231,7 @@ } @media (max-width: 700px) { .shell-header { - @apply gap-2 px-2; + @apply gap-2 pl-2; } } @media (max-width: 650px) { diff --git a/tests/browser/presence.spec.mjs b/tests/browser/presence.spec.mjs index 52ea60725..9a1378baa 100644 --- a/tests/browser/presence.spec.mjs +++ b/tests/browser/presence.spec.mjs @@ -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, }) => { @@ -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"); } diff --git a/tests/browser/profile-avatar-gap.spec.mjs b/tests/browser/profile-avatar-gap.spec.mjs new file mode 100644 index 000000000..38b98ce9a --- /dev/null +++ b/tests/browser/profile-avatar-gap.spec.mjs @@ -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`) }); + } +}); diff --git a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx index 84585e241..ce11e3fdf 100644 --- a/tests/fixtures/design-system/ui/ButtonSpecimens.tsx +++ b/tests/fixtures/design-system/ui/ButtonSpecimens.tsx @@ -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"; @@ -232,6 +239,59 @@ export function IconButtonSpecimen() { +
+

+ Avatar · Transparent cutouts +

+

+ Hover, press, or open the menu: the backdrop stays visible through the + avatar cutout. Tab to the enabled control to inspect keyboard focus. +

+
+
+ + + } + /> + } + /> + + View profile + + + + } + /> +
+
+