Skip to content
Open
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 docs/design-system-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Use this map to find the shared owner before changing a product surface.
| Area | Shared owner | App uses |
| --- | --- | --- |
| Colors and type | Surface, text, border, affordance, and complete type roles | Host aliases forward to shared roles. Feature CSS uses role names; inline links pair text and hover roles. |
| Actions | Button and IconButton | Retry, refresh, delete, recovery, composer send, and picker triggers. Standard sizes are 32/40/52px minimums. Labels stay on one line; surrounding layouts reflow whole controls or scroll. |
| Actions | Button and IconButton | Retry, refresh, delete, recovery, composer send, and picker triggers. Standard sizes are 32/40/52px; IconButton defaults to small and labelled Button defaults to medium. Text-button heights are minimums. Labels stay on one line; surrounding layouts reflow whole controls or scroll. |
| Forms and choices | Field, Input, Textarea, RadioGroup, Checkbox | Profile, community setup, appearance, plugin import, and workflow editing. |
| Search | SearchField | Channels, pages, members, and GIFs retain their query, refs, and keyboard handlers. |
| Navigation | NavigationItem | Settings, channel rows, shell destinations, Home, and community choices. Route destinations use button semantics; the wide shell topbar is the documented chrome-tab exception. |
Expand Down
44 changes: 31 additions & 13 deletions src/bundled/channels/ChannelMembersDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import {
act,
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/react";
import { createHash } from "node:crypto";
import { schnorr } from "@noble/curves/secp256k1.js";
import { bytesToHex } from "nostr-tools/utils";
Expand Down Expand Up @@ -1285,7 +1292,11 @@ async function pagedInvitations(
.flatMap(([filters]) => filters)
.filter((filter) => filter.search)
.map((filter) => filter.page);
await step(() => t.user.type(input, "Helper"));
// Paging is the contract here, not individual keystrokes.
// Keep the actual search debounce and session; bulk changes avoid unrelated work.
await step(async () =>
fireEvent.change(input, { target: { value: "Helper" } }),
);
await idle();
return { t, input, refresh, idle, more, rows, pages };
}
Expand All @@ -1294,10 +1305,11 @@ it("pages combined local and relay invitations without losing matches", async ({
signal,
}) => {
return ownedTask(signal, async (step) => {
const { t, more, rows, pages } = await pagedInvitations(step, 65);
expect(rows()).toHaveLength(30);
const { t, idle, more, rows, pages } = await pagedInvitations(step, 65);
const firstPage = rows();
expect(firstPage).toHaveLength(30);
expect(
rows().every((row) =>
firstPage.every((row) =>
row.getAttribute("aria-label")?.startsWith("Add Helper remote"),
),
).toBe(true);
Expand All @@ -1308,9 +1320,12 @@ it("pages combined local and relay invitations without losing matches", async ({
expect(pages()).toEqual([1]);
}
await more();
await step(() =>
screen.findByRole("button", { name: /^Add Helper final/ }),
);
// Settle the page before querying all its accessible names; retrying a
// missing-row query repeatedly walks and formats the large existing list.
await idle();
expect(
screen.getByRole("button", { name: /^Add Helper final/ }),
).toBeVisible();
expect(rows()).toHaveLength(96);
expect(pages()).toEqual([1, 2]);
expect(
Expand All @@ -1328,9 +1343,10 @@ it("resets combined invitation paging on refresh or query change", async ({
const { t, input, refresh, idle, more, rows, pages } =
await pagedInvitations(step, 31);
for (let i = 0; i < 3; i++) await more();
await step(() =>
screen.findByRole("button", { name: /^Add Helper final/ }),
);
await idle();
expect(
screen.getByRole("button", { name: /^Add Helper final/ }),
).toBeVisible();
expect(rows()).toHaveLength(62);
expect(pages()).toEqual([1, 2]);
await step(() => t.user.click(refresh));
Expand All @@ -1339,10 +1355,12 @@ it("resets combined invitation paging on refresh or query change", async ({
expect(pages()).toEqual([1, 2, 1]);
await more();
expect(rows()).toHaveLength(60);
await step(() => t.user.type(input, " local"));
await step(async () =>
fireEvent.change(input, { target: { value: "Helper local" } }),
);
await idle();
expect(rows()).toHaveLength(30);
await step(() => t.user.clear(input));
await step(async () => fireEvent.change(input, { target: { value: "" } }));
expect(
screen.queryByRole("region", { name: "Not in this channel" }),
).toBeNull();
Expand Down
2 changes: 2 additions & 0 deletions src/features/communities/CommunityRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export function CommunityRail({
<Tooltip content="Personal space" side="right">
<IconButton
ref={personalRef}
size="md"
aria-label="Personal space"
aria-current={client.selected === null ? "true" : undefined}
data-selected={client.selected === null || undefined}
Expand Down Expand Up @@ -232,6 +233,7 @@ export function CommunityRail({
<Tooltip content="Add a community" side="right">
<IconButton
ref={addRef}
size="md"
aria-label="Add a community"
icon={<PlusIcon size={22} aria-hidden="true" />}
onClick={() => setJoining(true)}
Expand Down
1 change: 1 addition & 0 deletions src/features/communities/CommunityRailItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function CommunityRailItem({
button.current = node;
buttonRef?.(node);
}}
size="md"
aria-label={`Switch to ${name}`}
aria-current={selected ? "true" : undefined}
data-selected={selected || undefined}
Expand Down
1 change: 1 addition & 0 deletions src/features/profiles/AvatarEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function AvatarEditor(props: Props) {
render={
<IconButton
variant="solid"
size="md"
shape="round"
style={{
width: "100%",
Expand Down
8 changes: 6 additions & 2 deletions src/shared/design-system/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,8 +629,12 @@ side padding and 16px icons; medium and large use 24px side padding and 24px ico
Standard Button labels use the complete `text-label-sm` role with an 8px icon gap; the
extra-small capsule uses `text-caption`.

IconButton defaults to a 10px rounded rectangle (`--radius-icon-button`) and uses the same
sm/md/lg sizes. Existing names
Button defaults to `md` (40px minimum height); IconButton defaults to `sm`
(32px square with 16px artwork) and a 10px rounded rectangle
(`--radius-icon-button`). Choose `md` or `lg` explicitly when an icon action needs
a larger control; community rail identities and the avatar edit badge retain their
existing larger treatment. These defaults do not depend on pointer type or viewport
width. Both controls use the same sm/md/lg scale. Existing names
remain compatibility aliases: primary/solid → prominent, quiet → subtle,
compact/toolbar → sm, default → md, large → lg. Do not add new alias call sites.
Buzz's tint and chrome icon variants remain for composer and backdrop actions.
Expand Down
2 changes: 1 addition & 1 deletion src/shared/design-system/ui/IconButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type IconButtonProps = Omit<
export function IconButton({
icon,
variant = "ghost",
size = "md",
size = "sm",
shape = variant === "avatar" ? "round" : "control",
...props
}: IconButtonProps) {
Expand Down
35 changes: 35 additions & 0 deletions src/shared/design-system/ui/controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createRef, useState } from "react";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import { PlusIcon } from "../icons";
import { Field } from "./Field";
import { Input } from "./Input";
import { Textarea } from "./Textarea";
Expand All @@ -15,6 +17,39 @@ import { Composer } from "./Composer";

afterEach(cleanup);

test("icon actions default to small while labelled actions remain medium", () => {
render(
<>
<IconButton aria-label="Add item" icon={<PlusIcon />} />
<Button>Continue</Button>
</>,
);
expect(screen.getByRole("button", { name: "Add item" })).toHaveAttribute(
"data-size",
"sm",
);
expect(screen.getByRole("button", { name: "Continue" })).toHaveAttribute(
"data-size",
"md",
);
});

test.each([
["sm", "sm"],
["md", "md"],
["lg", "lg"],
["compact", "sm"],
["toolbar", "sm"],
["default", "md"],
["large", "lg"],
["xs", "sm"],
] as const)("explicit icon size %s retains its contract", (size, resolved) => {
render(<IconButton size={size} aria-label="Add item" icon={<PlusIcon />} />);
const control = screen.getByRole("button", { name: "Add item" });
expect(control).toHaveAttribute("data-size", resolved);
expect(control).toHaveAttribute("data-icon-size", size);
});

test("composer enables submission only for an available non-empty draft", async () => {
const user = userEvent.setup();
const submit = vi.fn((event) => event.preventDefault());
Expand Down
4 changes: 2 additions & 2 deletions src/shared/design-system/ui/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"outline",
"inverted",
"link",
"size: xs | sm | md | lg",
"size: xs | sm | md (default) | lg",
"shape: capsule (default) | control (12px)",
"loading",
],
Expand Down Expand Up @@ -445,7 +445,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"media",
"bare (glyph-state toggle, no background in any state; 20px artwork at sm)",
"shape: control (default, 10px) | round (avatar default) | row-end",
"size: xs (20px, 12px icon) | sm (32px) | md (40px) | lg (52px)",
"size: xs (20px, 12px icon) | sm (default, 32px, 16px icon) | md (40px) | lg (52px)",
"legacy aliases: quiet, solid, compact, toolbar, default, large",
],
status: "proposed",
Expand Down
11 changes: 10 additions & 1 deletion tests/browser/avatar-edit.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ test("avatar image controls retain their hover fill and accessible URL label", a
await page
.getByRole("button", { name: "Edit human profile", exact: true })
.click();
await page.getByRole("button", { name: "Edit avatar", exact: true }).click();
const editAvatar = page.getByRole("button", {
name: "Edit avatar",
exact: true,
});
// The badge owns its outer box; opting into medium preserves the artwork
// without replacing that human/agent geometry with the new compact default.
await expect(editAvatar).toHaveCSS("width", "32px");
await expect(editAvatar).toHaveCSS("height", "32px");
await expect(editAvatar.locator("svg")).toHaveCSS("width", "24px");
await editAvatar.click();
const dropzone = page.getByRole("button", {
name: "Drop or browse",
exact: true,
Expand Down
2 changes: 1 addition & 1 deletion tests/browser/plugin-import.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ test("Settings controls scale together and keep enlarged labels reachable", asyn
// Icon buttons grow with the interface while keeping their square shape.
await expect(
page.getByRole("button", { name: "Search Buzz", exact: true }),
).toHaveAttribute("data-icon-size", "md");
).toHaveAttribute("data-icon-size", "sm");
await expect
.poll(() =>
page
Expand Down
21 changes: 21 additions & 0 deletions tests/browser/sidenav-polish.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,27 @@ test("top bar uses consistent square controls and translucent ghost fills", asyn
await expect(overflow).toBeHidden();
await expect(topbarPages).toBeVisible();
}
// The icon default is compact, but community identities keep their larger
// targets. Host CSS must not accidentally erase those explicit exceptions.
const rail = page.getByRole("navigation", {
name: "Communities",
exact: true,
});
for (const control of await rail.getByRole("button").all()) {
const box = await control.boundingBox();
expect([box.width, box.height]).toEqual([40, 40]);
}
await header
.getByRole("button", { name: "Your profile", exact: true })
.click();
const profileAvatar = page.getByRole("menuitem", {
name: "View your profile",
});
await expect(profileAvatar).toBeVisible();
const avatarBox = await profileAvatar.boundingBox();
expect([avatarBox.width, avatarBox.height]).toEqual([40, 40]);
await page.keyboard.press("Escape");
await expect(profileAvatar).toHaveCount(0);
expect(await controls.count()).toBeGreaterThanOrEqual(5);
for (const control of await controls.all()) {
const box = await control.boundingBox();
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/design-system/ui/ButtonSpecimens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,10 @@ export function IconButtonSpecimen() {
const [unread, setUnread] = useState(false);
return (
<div className="component-specimen-stack">
<p className="text-body-sm text-subtle">
Icon actions default to small: a 32px control with 16px artwork.
Labelled buttons stay medium; choose a larger icon size explicitly.
</p>
<ExamplePreview
className="justify-center"
code={`<IconButton
Expand Down
14 changes: 14 additions & 0 deletions tests/fixtures/design-system/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1200,9 +1200,23 @@ test("buttons and icon buttons share size geometry and preserve loading and disa
});
await expect(samples).toBeVisible();
await page.evaluate(() => document.fonts.ready);
const defaultControl = page
.getByRole("button", {
name: kind === "button" ? "Continue" : "Add item",
exact: true,
})
.first();
for (const mode of ["light", "dark"]) {
const toggle = page.getByRole("button", { name: `Use ${mode} mode` });
if (await toggle.count()) await toggle.click();
await expect(defaultControl).toHaveCSS(
"height",
kind === "button" ? "40px" : "32px",
);
if (kind === "icon-button") {
await expect(defaultControl).toHaveCSS("width", "32px");
await expect(defaultControl.locator("svg")).toHaveCSS("width", "16px");
}
for (const [size, height, artwork] of [
["sm", 32, 16],
["md", 40, 24],
Expand Down
Loading