diff --git a/scripts/design-system/check-contrast.mjs b/scripts/design-system/check-contrast.mjs
index 18f014673..c23cb41d0 100644
--- a/scripts/design-system/check-contrast.mjs
+++ b/scripts/design-system/check-contrast.mjs
@@ -109,6 +109,8 @@ const TEXT_ROLES = [
* every fill it can actually sit on, and hover is one of them.
*/
const PAIRS = [
+ ["--text-standard", "--affordance-floating-hover"],
+ ["--text-danger", "--affordance-floating-hover"],
["--text-inverse", "--surface-inverse"],
["--text-link", "--affordance-link-hover"],
...["subtle", "subtle-hover", "subtle-pressed"].map((state) => [
diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs
index 1f48c963c..0f04f5560 100644
--- a/scripts/design-system/check-type.mjs
+++ b/scripts/design-system/check-type.mjs
@@ -79,6 +79,10 @@ const RETIRED_ROLES = new Map([
* wrong. Fix the system at that point rather than adding a tenth override.
*/
const OVERRIDES = new Map([
+ [
+ "styles/search-field.css:font-size:",
+ "The capsule recipe forwards the text-body-sm role into Emoji Mart's shadow root, where Tailwind utilities cannot reach.",
+ ],
[
"styles/flex-workspace.css:font-size:",
"FlexLayout's public theme variable forwards the existing text-body role; no new text size is authored.",
diff --git a/src/bundled/agents/AgentCard.tsx b/src/bundled/agents/AgentCard.tsx
index abae67d64..7ebb5292e 100644
--- a/src/bundled/agents/AgentCard.tsx
+++ b/src/bundled/agents/AgentCard.tsx
@@ -1,5 +1,12 @@
import { useRef, type ReactNode } from "react";
-import { Menu } from "@base-ui/react/menu";
+import {
+ MenuRoot,
+ MenuTrigger,
+ MenuPopup,
+ MenuItem,
+ MenuNote,
+} from "../../shared/design-system/ui/Menu";
+import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
import {
DotsThreeIcon,
UsersIcon,
@@ -45,8 +52,8 @@ export function AgentCard({
>
{onEdit && (
-
-
+
}
/>
-
-
-
- {editable.length ? (
- editable.map((agent) => (
- {
- // The menu item unmounts; return from the dialog to the card.
- trigger.current?.focus();
- onEdit(agent, picture);
- }}
- >
- {editable.length === 1 ? (
- "Edit"
- ) : (
+
+ {editable.length ? (
+ editable.map((agent) => (
+ {
+ // The menu item unmounts; return from the dialog to the card.
+ trigger.current?.focus();
+ onEdit(agent, picture);
+ }}
+ >
+ {editable.length === 1 ? (
+ "Edit"
+ ) : (
+
- Edit {identityLabel(agent)}
{agent.relayUrl}
@@ -85,28 +88,23 @@ export function AgentCard({
{agent.pubkey}
>
- )}
-
- ))
- ) : (
- <>
-
- Edit
-
-
- {identities.length
- ? "Import this identity to edit in Foundation."
- : "No linked identity to edit."}
-
- >
- )}
-
-
-
-
+ }
+ />
+ )}
+
+ ))
+ ) : (
+ <>
+
Edit
+
+ {identities.length
+ ? "Import this identity to edit in Foundation."
+ : "No linked identity to edit."}
+
+ >
+ )}
+
+
)}
{
+ const user = userEvent.setup();
+ const item: ThreadActivityItem = {
+ channelId: "studio",
+ rootId: "thread",
+ latestMessageId: "message",
+ authorId: "alex",
+ createdAt: 1,
+ preview: "Please review the designs",
+ unreadCount: 2,
+ };
+ const snapshot: ThreadActivitySnapshot = {
+ channelId: "studio",
+ items: [item],
+ coverage: "observed",
+ freshness: "stale",
+ };
+ const profiles = new Map([["alex", { name: "Alex" }]]);
+ const ensure = vi.fn(async () => {});
+ const open = vi.fn();
+ const session = {
+ unread: { activity: () => snapshot, subscribeActivity: () => () => {} },
+ profiles: { snapshot: () => profiles, subscribe: () => () => {}, ensure },
+ } as unknown as RelaySession;
+ render(
+
Activity}
+ onOpenThread={open}
+ />,
+ );
+ expect(ensure).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Activity" }));
+ expect(
+ await screen.findByRole("dialog", { name: "Activity in Studio" }),
+ ).toBeVisible();
+ expect(ensure).toHaveBeenCalledExactlyOnceWith(["alex"], "background");
+ expect(screen.getByText("May be out of date")).toBeVisible();
+ await user.click(
+ screen.getByRole("button", {
+ name: "Open unread thread from Alex: Please review the designs",
+ }),
+ );
+ expect(open).toHaveBeenCalledExactlyOnceWith(item);
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
+ );
+});
diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx
index b49fd14ad..9a2d74c85 100644
--- a/src/bundled/channels/ChannelActivityPopover.tsx
+++ b/src/bundled/channels/ChannelActivityPopover.tsx
@@ -1,6 +1,10 @@
import { useChannelIdentityNames } from "../../features/identity-names/react";
import { NavigationItem } from "../../shared/design-system/ui/NavigationItem";
-import { Popover } from "@base-ui/react/popover";
+import {
+ PopoverRoot,
+ PopoverTrigger,
+ PopoverPopup,
+} from "../../shared/design-system/ui/Popover";
import {
useCallback,
useMemo,
@@ -104,7 +108,7 @@ export function ChannelActivityPopover({
if (!items.length) return trigger;
const stale = snapshot.freshness === "stale";
return (
- {
@@ -118,44 +122,37 @@ export function ChannelActivityPopover({
.catch(() => {});
}}
>
-
-
-
-
- {stale && (
- May be out of date
- )}
- {open && (
-
- {items.map((item) => (
-
{
- setOpen(false);
- onOpenThread(selected);
- }}
- />
- ))}
-
- )}
-
-
-
-
+
+ {stale && May be out of date
}
+ {open && (
+
+ {items.map((item) => (
+
{
+ setOpen(false);
+ onOpenThread(selected);
+ }}
+ />
+ ))}
+
+ )}
+
+
);
}
diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css
index a14a07b92..284d007bd 100644
--- a/src/bundled/channels/Channels.module.css
+++ b/src/bundled/channels/Channels.module.css
@@ -456,21 +456,6 @@ html[data-keyboard-navigation]
.threadActivityDot {
cursor: help;
}
-.activityPopover {
- @apply popover-surface;
- width: min(360px, calc(100vw - 24px));
- max-height: min(420px, calc(100vh - 24px));
- overflow: hidden;
- transform-origin: var(--transform-origin);
- transition:
- opacity 120ms ease-out,
- transform 120ms ease-out;
-}
-.activityPopover[data-starting-style],
-.activityPopover[data-ending-style] {
- opacity: 0;
- transform: scale(0.98);
-}
.activityStale {
margin: 0;
padding: var(--space-1h) var(--space-3);
diff --git a/src/bundled/composer/lab/fixture.tsx b/src/bundled/composer/lab/fixture.tsx
index 8287a577f..da06ca7aa 100644
--- a/src/bundled/composer/lab/fixture.tsx
+++ b/src/bundled/composer/lab/fixture.tsx
@@ -46,6 +46,11 @@ const rawSession = {
reply: () => "preview-reply",
},
typing: { snapshot: () => emptyList, subscribe: empty },
+ names: {
+ snapshot: () => 0,
+ subscribe: empty,
+ resolve: (_pubkey: string, fallback: string) => fallback,
+ },
profiles: {
snapshot: () => profiles,
subscribe: empty,
diff --git a/src/bundled/emoji/Emoji.module.css b/src/bundled/emoji/Emoji.module.css
index 5351adaa8..9498f93d6 100644
--- a/src/bundled/emoji/Emoji.module.css
+++ b/src/bundled/emoji/Emoji.module.css
@@ -13,51 +13,45 @@
margin: 0;
min-width: 0;
}
-.emojiPopover {
- @apply popover-surface;
- --picker-container-radius: var(--radius-card);
- border-radius: var(--picker-container-radius);
- --picker-inner-radius: calc(var(--picker-container-radius) - var(--space-2));
- --picker-scrollbar-space: var(--space-4);
- --picker-tab-foreground: var(--text-standard);
- --picker-tab-muted: var(--text-subtle);
- --picker-tab-selected: var(--affordance-selected);
- --picker-scrollbar-thumb: var(--border-prominent);
- position: absolute;
- bottom: 42px;
- left: 0;
- max-width: 100%;
- overflow: hidden;
+.emojiContent {
+ display: flex;
+ flex-direction: column;
+ max-height: calc(var(--available-height) - 2px);
}
-.sharedSearchIcon {
- position: absolute;
- top: calc(var(--space-2) + (var(--size-control) - var(--space-4)) / 2);
- left: calc(var(--space-2) + var(--space-control-inset));
- z-index: 4;
- color: var(--text-subtle);
- pointer-events: none;
+/* The panel gap comes from Tabs, so search adds no second top gap. */
+.emojiContent[data-tabs] {
+ --picker-search-top: 0px;
}
-.reactionPositioner {
- z-index: var(--layer-popover);
+.emojiContent [role="tablist"] {
+ flex-shrink: 0;
+ margin: var(--space-3) var(--space-3) 0;
}
-.reactionPopover {
- position: relative;
- bottom: auto;
- left: auto;
- max-width: calc(100vw - 32px);
+.emojiContent > :first-child,
+.emojiContent [role="tabpanel"] {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+.emojiContent [role="tabpanel"][hidden] {
+ display: none;
+}
+.emojiContent:not([data-tabs]) > :first-child {
+ gap: 0;
}
-@media (max-width: 900px) {
- .emojiPopover {
- --picker-container-radius: var(--radius-card-compact);
- border-radius: var(--radius-card-compact);
- }
+.emojiContent:not([data-tabs]) [role="tablist"] {
+ display: none;
}
.emojiMart {
position: relative;
+ height: min(348px, 40vh);
+ min-height: 0;
+}
+.emojiMart > div {
+ height: 100%;
}
.emojiMart em-emoji-picker {
width: 100%;
- height: min(348px, 40vh);
+ height: 100%;
--border-radius: 0;
--shadow: none;
}
@@ -71,41 +65,20 @@
letter-spacing: var(--text-caption--letter-spacing);
}
.gifPicker {
+ min-height: 0;
position: relative;
display: flex;
height: min(348px, 40vh);
- min-height: 230px;
flex-direction: column;
}
.gifSearch {
- margin: var(--space-2);
+ margin: var(--picker-search-top, var(--space-3)) var(--space-3) var(--space-3);
}
.gifResults {
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 0 var(--space-3) var(--space-2);
- scrollbar-width: none;
-}
-.gifResults::-webkit-scrollbar {
- display: none;
-}
-.gifScrollbarTrack {
- position: absolute;
- right: 4px;
- z-index: 4;
- width: 8px;
- opacity: 0.6;
- pointer-events: none;
-}
-.gifScrollbarThumb {
- position: absolute;
- top: 0;
- left: 0;
- width: 8px;
- min-height: 32px;
- border-radius: var(--radius-pill);
- background: var(--picker-scrollbar-thumb);
}
.gifGrid {
columns: 2;
@@ -157,7 +130,11 @@
text-align: center;
}
.gifAttribution {
- padding: var(--space-1h) var(--space-2);
+ display: grid;
+ height: var(--size-control);
+ flex-shrink: 0;
+ place-items: center;
+ padding: 0 var(--space-2);
border-top: 1px solid var(--border-standard);
color: var(--text-subtle);
font-size: var(--text-caption);
diff --git a/src/bundled/emoji/EmojiPicker.tsx b/src/bundled/emoji/EmojiPicker.tsx
index 5eb18f485..8ed628c78 100644
--- a/src/bundled/emoji/EmojiPicker.tsx
+++ b/src/bundled/emoji/EmojiPicker.tsx
@@ -4,18 +4,20 @@ import { IconButton } from "../../shared/design-system/ui/IconButton";
import {
type RefObject,
useEffect,
- useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
import {
- MagnifyingGlassIcon,
SmileyIcon,
SmileyStickerIcon,
} from "../../shared/design-system/icons/index";
-import { Popover } from "@base-ui/react/popover";
+import {
+ PopoverRoot,
+ PopoverTrigger,
+ PopoverPopup,
+} from "../../shared/design-system/ui/Popover";
import type { RelaySession } from "../../features/relay/session";
import {
communityFromScope,
@@ -59,7 +61,10 @@ export function EmojiPicker({
supported: boolean | undefined;
}>();
const [gifDiscoveryRequested, setGifDiscoveryRequested] = useState(false);
- const [perLine, setPerLine] = useState(0);
+ const [{ perLine, availableWidth }, setLayout] = useState({
+ perLine: 0,
+ availableWidth: 0,
+ });
const [error, setError] = useState();
const [attempt, retry] = useState(0);
const trigger = useRef(null);
@@ -68,8 +73,7 @@ export function EmojiPicker({
const search = useRef("");
const onInsert = useRef(insert);
onInsert.current = insert;
- const ownId = useId();
- const id = externalTrigger?.id ?? ownId;
+ const accepted = useRef(false);
const community = reaction ? undefined : communityFromScope(scope);
const gifs = community
? gifAvailability?.community === community
@@ -82,7 +86,7 @@ export function EmojiPicker({
session.emoji.snapshot,
session.emoji.snapshot,
);
- useEffect(() => {
+ useLayoutEffect(() => {
// The popover is positioned against the action row; intermediate tool groups
// may be narrower and are not its available width.
const container = reaction
@@ -90,8 +94,9 @@ export function EmojiPicker({
: controls.current?.offsetParent;
if (!open || disabled || !(container instanceof HTMLElement)) return;
const resize = () =>
- setPerLine(
- Math.max(
+ setLayout({
+ availableWidth: container.clientWidth,
+ perLine: Math.max(
1,
Math.min(
6,
@@ -100,7 +105,7 @@ export function EmojiPicker({
),
),
),
- );
+ });
resize();
const observer = new ResizeObserver(resize);
observer.observe(container);
@@ -135,18 +140,6 @@ export function EmojiPicker({
);
return () => controller.abort();
}, [community, gifDiscoveryRequested, gifAvailability]);
- useEffect(() => {
- if (!open || disabled || reaction) return;
- function outside(event: PointerEvent) {
- if (
- event.target instanceof Node &&
- !controls.current?.contains(event.target)
- )
- setOpen(false);
- }
- document.addEventListener("pointerdown", outside);
- return () => document.removeEventListener("pointerdown", outside);
- }, [open, disabled, reaction]);
// biome-ignore lint/correctness/useExhaustiveDependencies: attempt explicitly retries a failed lazy import.
useLayoutEffect(() => {
if (!open || disabled || tab !== "emoji" || !host || !perLine) return;
@@ -172,6 +165,7 @@ export function EmojiPicker({
media: session.media,
select: (value) => {
onInsert.current(value);
+ accepted.current = true;
setOpen(false);
},
close: () => setOpen(false),
@@ -193,11 +187,6 @@ export function EmojiPicker({
}, [open, disabled, session, scope, catalog, attempt, perLine, tab, host]);
const emojiContent = (
);
@@ -210,6 +199,7 @@ export function EmojiPicker({
}}
select={(gif) => {
onInsert.current(gifMarkdown(gif));
+ accepted.current = true;
setOpen(false);
setTab("emoji");
@@ -217,29 +207,19 @@ export function EmojiPicker({
/>
);
const picker = (
-
- {showGifTab ? (
-
- value === "emoji" ? emojiContent : gifContent
- }
- />
- ) : (
- emojiContent
- )}
+
+
(value === "emoji" ? emojiContent : gifContent)}
+ />
+
{tab === "emoji" && error && (
Could not load emoji picker: {error}
@@ -256,7 +236,7 @@ export function EmojiPicker({
)}
-
+
);
const button = (
setGifDiscoveryRequested(true)}
onFocus={() => setGifDiscoveryRequested(true)}
- onClick={() => {
- if (open) {
- setOpen(false);
- return;
- }
-
- void session.emoji.ensure();
- if (gifs !== true && gifAvailability?.community === community)
- setGifAvailability(undefined);
- setGifDiscoveryRequested(true);
- setOpen(true);
- }}
icon={
reaction ? (
@@ -308,46 +274,66 @@ export function EmojiPicker({
? externalTrigger.finalFocus()
: trigger.current;
if (target) target.focus();
+ // Keyboard dismissal is immediate; release the external trigger before
+ // another Enter can arrive, rather than waiting for exit completion.
+ externalTrigger?.close();
}
}}
>
- {externalTrigger ? null : reaction ? (
-
- ) : (
- button
+ {!externalTrigger && (
+
)}
- {reaction ? (
-
-
-
-
-
- ) : open && !disabled ? (
- picker
- ) : null}
+ controls.current?.offsetParent ?? null
+ }
+ sideOffset={reaction ? 6 : 8}
+ collisionPadding={16}
+ // Each media panel focuses its search after its asynchronous content mounts.
+ initialFocus={false}
+ padding="none"
+ id={externalTrigger?.id}
+ aria-label="Emoji picker"
+ style={{
+ width: Math.min(
+ availableWidth,
+ perLine * EMOJI_SLOT + PICKER_CHROME + 2,
+ ),
+ overflow: "hidden",
+ }}
+ finalFocus={() =>
+ externalTrigger
+ ? externalTrigger.finalFocus()
+ : accepted.current && !reaction
+ ? false
+ : trigger.current
+ }
+ >
+ {picker}
+
);
- return reaction ? (
- {
if (!isOpen) externalTrigger?.close();
}}
+ onOpenChange={(next) => {
+ setOpen(next);
+ if (next) {
+ accepted.current = false;
+ void session.emoji.ensure();
+ if (gifs !== true && gifAvailability?.community === community)
+ setGifAvailability(undefined);
+ setGifDiscoveryRequested(true);
+ }
+ }}
>
{controlsView}
-
- ) : (
- controlsView
+
);
}
diff --git a/src/bundled/emoji/GifPicker.tsx b/src/bundled/emoji/GifPicker.tsx
index 20a606af8..f82729490 100644
--- a/src/bundled/emoji/GifPicker.tsx
+++ b/src/bundled/emoji/GifPicker.tsx
@@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { SearchField } from "../../shared/design-system/ui/SearchField";
import { Button } from "../../shared/design-system/ui/Button";
import { fetchKlipyGifs, type KlipyGif } from "../../features/relay/gifs";
+import "../../shared/design-system/styles/scrollbars.css";
import styles from "./Emoji.module.css";
const LOADING_TILES = [
@@ -32,53 +33,9 @@ export function GifPicker({
const [error, setError] = useState();
const [attempt, retry] = useState(0);
const input = useRef(null);
- const results = useRef(null);
- const scrollbar = useRef(null);
- const scrollbarThumb = useRef(null);
-
useLayoutEffect(() => {
input.current?.focus();
}, []);
- useLayoutEffect(() => {
- const scroll = results.current;
- const track = scrollbar.current;
- const thumb = scrollbarThumb.current;
- if (!scroll || !track || !thumb) return;
- const update = () => {
- const picker = scroll.parentElement?.getBoundingClientRect();
- const bounds = scroll.getBoundingClientRect();
- if (!picker) return;
- const trackHeight = Math.max(0, scroll.clientHeight - 16);
- const overflow = scroll.scrollHeight - scroll.clientHeight;
- track.hidden = overflow <= 0;
- track.style.top = `${bounds.top - picker.top + 8}px`;
- track.style.height = `${trackHeight}px`;
- if (overflow <= 0) return;
- const thumbHeight = Math.max(
- 32,
- trackHeight * (scroll.clientHeight / scroll.scrollHeight),
- );
- const offset =
- (scroll.scrollTop / overflow) * Math.max(0, trackHeight - thumbHeight);
- thumb.style.height = `${thumbHeight}px`;
- thumb.style.transform = `translateY(${offset}px)`;
- };
- const resize = new ResizeObserver(update);
- resize.observe(scroll);
- if (scroll.firstElementChild) resize.observe(scroll.firstElementChild);
- const mutations = new MutationObserver(() => {
- if (scroll.firstElementChild) resize.observe(scroll.firstElementChild);
- update();
- });
- mutations.observe(scroll, { childList: true });
- scroll.addEventListener("scroll", update, { passive: true });
- update();
- return () => {
- resize.disconnect();
- mutations.disconnect();
- scroll.removeEventListener("scroll", update);
- };
- }, []);
useEffect(() => {
const timeout = window.setTimeout(
() => setDebouncedQuery(query.trim()),
@@ -105,6 +62,7 @@ export function GifPicker({
-
+
{!gifs && !error ? (
No GIFs found.
)}
-
Powered by KLIPY
);
diff --git a/src/bundled/emoji/emoji-mart.ts b/src/bundled/emoji/emoji-mart.ts
index 0fae667ce..5364a3d30 100644
--- a/src/bundled/emoji/emoji-mart.ts
+++ b/src/bundled/emoji/emoji-mart.ts
@@ -1,3 +1,5 @@
+import scrollbarStyles from "../../shared/design-system/styles/scrollbars.css?raw";
+import searchFieldStyles from "../../shared/design-system/styles/search-field.css?raw";
import { pickerIcons } from "../../shared/design-system/icons/svg";
// Emoji Mart config adapted from block/buzz's shared picker; see NOTICE.md.
import data from "@emoji-mart/data";
@@ -155,8 +157,7 @@ export function mountEmojiMart({
--buzz-category-icon: var(--text-subtle);
--buzz-category-icon-selected: var(--text-standard);
--buzz-category-label: var(--text-subtle);
- --buzz-scrollbar-thumb: var(--border-prominent);
- background: var(--surface-panel);
+ background: var(--surface-popover);
color: var(--text-standard);
font-family: var(--font-sans);
}
@@ -167,85 +168,39 @@ export function mountEmojiMart({
}
.scroll {
padding-inline: var(--space-3);
- scrollbar-width: none;
- }
- .scroll::-webkit-scrollbar {
- display: none;
- }
- .buzz-scrollbar-track {
- position: absolute;
- right: 4px;
- z-index: 4;
- width: 8px;
- opacity: .6;
- pointer-events: none;
- }
- .buzz-scrollbar-thumb {
- position: absolute;
- top: 0;
- left: 0;
- width: 8px;
- min-height: 32px;
- border-radius: var(--radius-pill);
- background: var(--buzz-scrollbar-thumb);
}
.scroll > div {
width: 100% !important;
}
.category .sticky {
- background: var(--surface-panel);
+ background: var(--surface-popover);
color: var(--buzz-category-label);
font-size: var(--text-caption);
font-weight: var(--type-weight-normal);
letter-spacing: 0;
line-height: var(--text-caption--line-height);
}
- .search input[type="search"] {
- width: 100%;
- min-height: var(--size-control);
- height: auto;
- padding: var(--space-2)
- calc(var(--space-control-inset) + var(--space-4) + var(--space-2));
- margin: 0;
- border: 1px solid transparent;
- border-radius: var(--radius-control);
- background: var(--surface-inset);
- box-shadow: none;
- color: var(--text-standard);
- font-family: var(--font-sans);
- font-size: var(--text-body);
- line-height: var(--text-body--line-height);
- transition: border-color var(--duration-field-focus) var(--easing-state);
- }
- .search input[type="search"]:focus {
- background: var(--surface-inset);
- box-shadow: none;
- border-color: var(--border-prominent);
- outline: none;
- }
- :host([data-keyboard-navigation]) .search input[type="search"] {
- transition: none;
- }
- @media (prefers-reduced-motion: reduce) {
- .search input[type="search"] { transition: none; }
+ .search.search-field {
+ width: calc(100% - var(--space-2));
+ margin: var(--picker-search-top, var(--space-3)) var(--space-1) var(--space-3);
}
- .search input[type="search"]::placeholder,
.search .icon {
- color: var(--text-metadata);
- opacity: 1;
+ top: auto;
+ right: auto;
+ left: auto;
+ transform: none;
}
.search .loupe {
- visibility: hidden;
- }
- .search .delete {
- right: var(--space-1);
- width: var(--size-control-sm);
- height: var(--size-control-sm);
- padding: var(--space-2);
- border-radius: var(--radius-pill);
- color: var(--text-standard);
+ position: static;
+ width: 16px;
+ height: 16px;
+ flex: 0 0 16px;
+ order: 0;
+ color: var(--text-metadata);
+ opacity: 1;
+ pointer-events: none;
}
- .search .delete svg {
+ .search .icon svg {
width: 16px;
height: 16px;
}
@@ -253,9 +208,6 @@ export function mountEmojiMart({
.spacer {
height: 0;
}
- .spacer + .flex.flex-middle {
- padding-block: var(--space-2);
- }
.spacer + .flex.flex-middle > .flex.flex-auto.flex-center.flex-middle {
width: 0 !important;
height: 0 !important;
@@ -279,8 +231,12 @@ export function mountEmojiMart({
}
#nav {
box-sizing: border-box;
+ display: grid;
width: 100%;
- padding-inline: var(--padding);
+ height: var(--size-control);
+ flex-shrink: 0;
+ align-items: center;
+ padding: 0 var(--padding);
}
#nav > .flex.relative > button {
flex: 1 1 0;
@@ -337,7 +293,7 @@ export function mountEmojiMart({
backdrop-filter: none;
top: auto !important;
right: 8px !important;
- bottom: 42px !important;
+ bottom: var(--size-control) !important;
left: auto !important;
z-index: 100 !important;
transform-origin: 100% 100%;
@@ -358,62 +314,11 @@ export function mountEmojiMart({
transition-duration: 0ms;
}
}
+ ${scrollbarStyles}
+ ${searchFieldStyles}
`;
root?.appendChild(navigationStyle);
let skinToneObserver: MutationObserver | undefined;
- let scrollbarCleanup: (() => void) | undefined;
- let scrollbarScroll: HTMLElement | undefined;
- let scrollbarUpdate: (() => void) | undefined;
- const installPersistentScrollbar = () => {
- const scroll = root?.querySelector
(".scroll");
- const pickerRoot = root?.querySelector("#root");
- if (!scroll || !pickerRoot) return;
- if (scroll === scrollbarScroll) {
- scrollbarUpdate?.();
- return;
- }
- scrollbarCleanup?.();
- pickerRoot.querySelector(".buzz-scrollbar-track")?.remove();
- const track = host.ownerDocument.createElement("div");
- track.className = "buzz-scrollbar-track";
- track.setAttribute("aria-hidden", "true");
- const thumb = host.ownerDocument.createElement("div");
- thumb.className = "buzz-scrollbar-thumb";
- track.appendChild(thumb);
- pickerRoot.appendChild(track);
- const update = () => {
- const pickerBounds = pickerRoot.getBoundingClientRect();
- const scrollBounds = scroll.getBoundingClientRect();
- const trackHeight = Math.max(0, scroll.clientHeight - 16);
- const overflow = scroll.scrollHeight - scroll.clientHeight;
- track.hidden = overflow <= 0;
- track.style.top = `${scrollBounds.top - pickerBounds.top + 8}px`;
- track.style.height = `${trackHeight}px`;
- if (overflow <= 0) return;
- const thumbHeight = Math.max(
- 32,
- trackHeight * (scroll.clientHeight / scroll.scrollHeight),
- );
- const offset =
- (scroll.scrollTop / overflow) * Math.max(0, trackHeight - thumbHeight);
- thumb.style.height = `${thumbHeight}px`;
- thumb.style.transform = `translateY(${offset}px)`;
- };
- const resize = new ResizeObserver(update);
- resize.observe(scroll);
- if (scroll.firstElementChild) resize.observe(scroll.firstElementChild);
- scroll.addEventListener("scroll", update, { passive: true });
- scrollbarScroll = scroll;
- scrollbarUpdate = update;
- scrollbarCleanup = () => {
- resize.disconnect();
- scroll.removeEventListener("scroll", update);
- track.remove();
- scrollbarScroll = undefined;
- scrollbarUpdate = undefined;
- };
- update();
- };
const placeSkinToneInNavigation = () => {
const source = root?.querySelector(".skin-tone-button");
const navigation = root?.querySelector(
@@ -455,11 +360,14 @@ export function mountEmojiMart({
});
return true;
};
- const installSearchClearIcon = () => {
- const icon = root?.querySelector(".search .delete svg");
- if (!icon || icon.dataset.buzzCircleX) return;
+ const installSearchIcon = (
+ selector: string,
+ name: "x" | "magnifying-glass",
+ ) => {
+ const icon = root?.querySelector(selector);
+ if (!icon || icon.dataset.buzzSearchIcon) return;
const template = document.createElement("template");
- template.innerHTML = pickerIcons["x-circle"];
+ template.innerHTML = pickerIcons[name];
const replacement = template.content.firstElementChild;
if (!replacement) return;
// Preserve the widget-owned node so its renderer does not insert a second SVG.
@@ -468,15 +376,20 @@ export function mountEmojiMart({
for (const attribute of [...replacement.attributes])
icon.setAttribute(attribute.name, attribute.value);
icon.innerHTML = replacement.innerHTML;
- icon.dataset.buzzCircleX = "true";
+ icon.dataset.buzzSearchIcon = name;
icon.setAttribute("aria-hidden", "true");
};
const focusSearch = () => {
const input = root?.querySelector('input[type="search"]');
if (!root || !input || disposed) return;
placeSkinToneInNavigation();
- installPersistentScrollbar();
- installSearchClearIcon();
+ root.querySelector(".scroll")?.classList.add("buzz-thin-scrollbar");
+ input.parentElement?.classList.add("search-field");
+ root
+ .querySelector(".search .delete")
+ ?.setAttribute("data-search-clear", "");
+ installSearchIcon(".search .loupe svg", "magnifying-glass");
+ installSearchIcon(".search .delete svg", "x");
if (input.dataset.buzzSearchReady) return;
input.dataset.buzzSearchReady = "true";
input.addEventListener("input", () => searchChange(input.value));
@@ -500,7 +413,6 @@ export function mountEmojiMart({
disposed = true;
observer.disconnect();
skinToneObserver?.disconnect();
- scrollbarCleanup?.();
themeObserver.disconnect();
picker.remove(); // unregisters Mart's document listeners and observers
for (const id of values.keys()) delete Data?.emojis[id];
diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx
index 39e0075d0..79ccad879 100644
--- a/src/bundled/mentions/MentionPicker.tsx
+++ b/src/bundled/mentions/MentionPicker.tsx
@@ -1,4 +1,9 @@
import { DraftMentionRoster } from "../../features/messages/draft-mention-roster";
+import {
+ PopoverRoot,
+ PopoverTrigger,
+ PopoverPopup,
+} from "../../shared/design-system/ui/Popover";
import { availableMentionAgents } from "../../features/agents/mention-choices";
import { mentionChoices } from "./mention-choices";
import { useIdentityNames } from "../../features/identity-names/react";
@@ -13,13 +18,13 @@ import { AtIcon } from "../../shared/design-system/icons/index";
import {
useContext,
useEffect,
- useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { RelaySession } from "../../features/relay/session";
+import "../../shared/design-system/styles/scrollbars.css";
import styles from "./Mentions.module.css";
import type { ComposerToolProps } from "../../features/conversation/contracts";
@@ -46,7 +51,8 @@ export function MentionPicker({
const [search, setSearch] = useState("");
const [error, setError] = useState();
const trigger = useRef(null);
- const id = useId();
+ const accepted = useRef(false);
+ const searchInput = useRef(null);
const list = useSyncExternalStore(
session.channels.subscribeList,
session.channels.list,
@@ -110,145 +116,203 @@ export function MentionPicker({
),
);
return (
- {
- if (event.key === "Escape" && open) {
- event.preventDefault();
- event.stopPropagation();
- setOpen(false);
- trigger.current?.focus();
+ {
+ setOpen(next);
+ if (next) {
+ accepted.current = false;
+ if (!draftRoster) session.channels.ensureList();
}
}}
>
- {
- setOpen(!open);
- if (!draftRoster) session.channels.ensureList();
- }}
- icon={ }
- />
- {open && (
-
+ }
+ />
+ }
+ />
+ {
+ if (event.nativeEvent.isComposing) return;
+ const fromSearch = event.target === searchInput.current;
+ if (event.key === "Enter" && fromSearch) event.preventDefault();
+ if (
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey
+ )
+ return;
+ if (!["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) return;
+ const rows = Array.from(
+ event.currentTarget.querySelectorAll(
+ "[data-mention-choice]:not(:disabled)",
+ ),
+ );
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
+ if (
+ !fromSearch &&
+ !rows.includes(event.target as HTMLButtonElement)
+ )
+ return;
+ event.preventDefault();
+ event.stopPropagation();
+ const current = rows.indexOf(
+ document.activeElement as HTMLButtonElement,
+ );
+ const next =
+ current < 0
+ ? event.key === "ArrowDown"
+ ? 0
+ : rows.length - 1
+ : (current +
+ (event.key === "ArrowDown" ? 1 : -1) +
+ rows.length) %
+ rows.length;
+ rows[next]?.focus();
+ } else if (event.key === "Enter" && fromSearch) {
+ event.stopPropagation();
+ rows[0]?.click();
+ }
+ }}
aria-label="Mention a member or agent"
+ finalFocus={() => (accepted.current ? false : trigger.current)}
>
- {
- if (event.key === "Enter") event.preventDefault();
- }}
- />
- {!draftRoster && (
-
- {inviteAgents
- ? parentAdmission
+
+
{
+ if (event.key === "Enter") event.preventDefault();
+ }}
+ />
+ {!draftRoster && inviteAgents && (
+
+ {parentAdmission
? "Agents you mention join this session and its parent channel when you send, with access to their history."
- : "Agents you mention join this session when you send, with access to its history."
- : "Your agents are added to this channel when you send."}
-
- )}
- {agents.status === "loading" && Loading agents…
}
- {(agents.status === "error" || !!agents.error) && (
- void session.agentChoices.refresh()}
- >
- Retry agent list
-
- )}
- {error && {error}
}
- {!draftRoster && list.error && (
- Could not refresh channel membership.
- )}
- {!draftRoster &&
- (!inviteAgents || !!channel) &&
- !channel?.members && (
- Channel membership unavailable.
+ : "Agents you mention join this session when you send, with access to its history."}
+
)}
- {!draftRoster && (
- session.channels.refreshList?.()}
- >
- Refresh members
-
- )}
-
- {candidates.slice(0, 100).map(({ recipient, label }) => (
-
Loading agents…
+ )}
+ {(agents.status === "error" || !!agents.error) && (
+ {
- if (select(recipient)) setOpen(false);
- }}
- label={
-
- {label}
- {!members?.includes(recipient.pubkey) && (
-
- {inviteAgents
- ? parentAdmission
- ? "Adds to session and parent channel when you send"
- : "Adds to session when you send"
- : "Adds to channel when you send"}
-
- )}
-
- }
- title={recipient.pubkey}
- trailing={{recipient.pubkey.slice(0, 12)}}
- icon={
-
- }
- />
- ))}
- {candidates.length > 100 && (
- Narrow your search to see more members.
+ onClick={() => void session.agentChoices.refresh()}
+ >
+ Retry agent list
+
)}
- {members && !candidates.length && (
- No matching channel members.
+ {error && {error}
}
+ {!draftRoster && list.error && (
+ Could not refresh channel membership.
)}
+ {!draftRoster && (!inviteAgents || !!channel) && !members && (
+ Channel membership unavailable.
+ )}
+ {!draftRoster && (
+ session.channels.refreshList?.()}
+ >
+ Refresh members
+
+ )}
+
+ {candidates.slice(0, 100).map(({ recipient, label }) => (
+
{
+ if (select(recipient)) {
+ accepted.current = true;
+ setOpen(false);
+ }
+ }}
+ label={
+
+ {label}
+ {!members?.includes(recipient.pubkey) && (
+
+ {inviteAgents
+ ? parentAdmission
+ ? "Adds to session and parent channel when you send"
+ : "Adds to session when you send"
+ : "Adds to channel when you send"}
+
+ )}
+
+ }
+ title={recipient.pubkey}
+ trailing={{recipient.pubkey.slice(0, 12)}}
+ icon={
+
+ }
+ />
+ ))}
+ {candidates.length > 100 && (
+ Narrow your search to see more members.
+ )}
+ {members && !candidates.length && (
+ No matching channel members.
+ )}
+
-
- )}
-
+
+
+
);
}
diff --git a/src/bundled/mentions/Mentions.module.css b/src/bundled/mentions/Mentions.module.css
index ab878c049..8a743cb93 100644
--- a/src/bundled/mentions/Mentions.module.css
+++ b/src/bundled/mentions/Mentions.module.css
@@ -1,25 +1,31 @@
@reference "../../shared/styles/globals.css";
-.mentionPopover {
- @apply popover-surface;
- position: absolute;
- bottom: calc(100% + 8px);
- left: 0;
- width: min(380px, 75vw);
- max-height: 360px;
- overflow: auto;
+.mentionContent {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-height: 0;
padding: var(--space-3);
+ gap: var(--space-2);
}
-.mentionPopover label {
+.mentionContent > :not(.mentionChoices) {
+ flex-shrink: 0;
+}
+.mentionContent label {
display: grid;
gap: var(--space-1h);
}
-.mentionPopover p {
+.mentionContent p {
font-size: var(--text-caption);
line-height: var(--text-caption--line-height);
letter-spacing: var(--text-caption--letter-spacing);
}
.mentionChoices {
+ grid-auto-rows: max-content;
+ align-content: start;
+ min-height: 0;
+ overflow-y: auto;
+ overscroll-behavior: contain;
display: grid;
gap: var(--space-1);
}
@@ -29,7 +35,7 @@
letter-spacing: var(--text-caption--letter-spacing);
overflow-wrap: anywhere;
}
-.mentionPopover p {
+.mentionContent p {
color: var(--text-subtle);
}
.pickerControls {
diff --git a/src/bundled/mentions/session-agents.test.tsx b/src/bundled/mentions/session-agents.test.tsx
index 561aa9e8d..7879bc8d0 100644
--- a/src/bundled/mentions/session-agents.test.tsx
+++ b/src/bundled/mentions/session-agents.test.tsx
@@ -174,6 +174,65 @@ it("offers outside agents in the session mention picker while ordinary channel p
view.unmount();
test.library.dispose();
});
+it.each([true, false])(
+ "session picker discloses history access before selection: parent=%s",
+ async (parent) => {
+ const test = setup(parent);
+ const list: ReturnType = {
+ status: "ready",
+ channels: [
+ {
+ id: "parent",
+ name: "Session",
+ channelType: "session",
+ members: ["a".repeat(64)],
+ ...(parent ? { parentChannelId: "parent-channel" } : {}),
+ },
+ ],
+ };
+ const session = {
+ ...test.session,
+ channels: { ...test.session.channels, list: () => list },
+ };
+ const user = userEvent.setup();
+ const select = vi.fn(() => true);
+ const view = render(
+ ,
+ );
+ try {
+ await user.click(
+ screen.getByRole("button", { name: "Mention a member" }),
+ );
+ const agent = await screen.findByRole("button", {
+ name: `Outside agent ${test.key}`,
+ });
+ expect(
+ screen.getByText(
+ parent
+ ? "Agents you mention join this session and its parent channel when you send, with access to their history."
+ : "Agents you mention join this session when you send, with access to its history.",
+ ),
+ ).toBeVisible();
+ expect(select).not.toHaveBeenCalled();
+ await user.click(agent);
+ expect(select).toHaveBeenCalledWith({
+ pubkey: test.key,
+ name: "Outside agent",
+ });
+ } finally {
+ view.unmount();
+ test.library.dispose();
+ }
+ },
+);
+
it.each([true, false, null])(
"typed @ completion offers outside agents with correct admission: parent=%s",
async (parent) => {
diff --git a/src/features/direct-messages/NewMessage.recovery.test.tsx b/src/features/direct-messages/NewMessage.recovery.test.tsx
index 3f9b779d6..e1062f217 100644
--- a/src/features/direct-messages/NewMessage.recovery.test.tsx
+++ b/src/features/direct-messages/NewMessage.recovery.test.tsx
@@ -541,7 +541,7 @@ it.each(["picker", "completion"])(
screen.getByRole("button", { name: "Mention a member" }),
);
return within(
- screen.getByRole("region", { name: "Mention a member or agent" }),
+ screen.getByRole("dialog", { name: "Mention a member or agent" }),
);
}
// jsdom has no caret hit testing; append after the existing mention.
diff --git a/src/features/messages/MessageActionBar.test.tsx b/src/features/messages/MessageActionBar.test.tsx
index 7efc76d25..97a58ffe5 100644
--- a/src/features/messages/MessageActionBar.test.tsx
+++ b/src/features/messages/MessageActionBar.test.tsx
@@ -39,7 +39,10 @@ it("opens with keyboard, invokes a sibling action, and returns focus on Escape",
await screen.findByRole("menuitem", { name: "Edit message" }),
);
expect(action).toHaveBeenCalledOnce();
+ await waitFor(() => expect(screen.queryByRole("menu")).toBeNull());
await user.click(trigger);
+ const menu = await screen.findByRole("menu");
+ await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true));
await user.keyboard("{Escape}");
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx
index fa57e8eac..d0f5fb15c 100644
--- a/src/features/messages/MessageComposer.test.tsx
+++ b/src/features/messages/MessageComposer.test.tsx
@@ -1353,8 +1353,8 @@ it.each(
await view.user.click(
await screen.findByRole("menuitemradio", {
name: parent
- ? "Honey — adds to session and channel"
- : "Honey — adds to session",
+ ? "Honey Adds to session and channel"
+ : "Honey Adds to session",
}),
);
}
diff --git a/src/features/sessions/AgentChoice.tsx b/src/features/sessions/AgentChoice.tsx
index 756ca9263..71a43a02c 100644
--- a/src/features/sessions/AgentChoice.tsx
+++ b/src/features/sessions/AgentChoice.tsx
@@ -1,16 +1,21 @@
import { useIdentityNames } from "../identity-names/react";
import { Button } from "../../shared/design-system/ui/Button";
-import {
- RobotIcon,
- CheckIcon,
- CaretUpIcon,
-} from "../../shared/design-system/icons/index";
+import { RobotIcon, CaretUpIcon } from "../../shared/design-system/icons/index";
import { useAgentChoices } from "../agents/use-choices";
-import { Menu } from "@base-ui/react/menu";
+import {
+ MenuRoot,
+ MenuTrigger,
+ MenuPopup,
+ MenuItem,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuNote,
+} from "../../shared/design-system/ui/Menu";
+import { ChoiceRow } from "../../shared/design-system/ui/ChoiceRow";
+import { Avatar as ChoiceAvatar } from "../../shared/design-system/ui/Avatar";
import type { RelaySession } from "../relay/session";
import { Avatar } from "../../shared/Avatar";
import { avatarSource } from "../../shared/avatar-source";
-import completion from "../conversation/Completions.module.css";
import styles from "./Sessions.module.css";
export function agentAdmission(
@@ -75,9 +80,9 @@ export function AgentChoice({
? "Change selected agent"
: "Choose an agent";
return (
-
+
-
{selected ? (
@@ -101,88 +106,88 @@ export function AgentChoice({
disabled={disabled}
/>
-
-
+
-
-
-
+ }
+ label={emptyLabel}
+ />
+
+ {identities.map((agent) => {
+ const admission = agentAdmission(
+ agent.pubkey,
+ allowed,
+ sessionMembers,
+ parentMembers,
+ );
+ return (
+
-
- {emptyLabel}
-
-
-
-
- {identities.map((agent) => {
- const admission = agentAdmission(
- agent.pubkey,
- allowed,
- sessionMembers,
- parentMembers,
- );
- return (
-
-
-
- {agent.name}
- {admission === "channel"
- ? " — adds to channel"
- : admission === "session-and-channel"
- ? " — adds to session and channel"
- : admission === "session"
- ? " — adds to session"
- : ""}
-
-
-
-
-
- );
- })}
-
- {allowed !== undefined &&
- sessionMembers === undefined &&
- agents.identities.some(
- (agent) => !allowed.includes(agent.pubkey),
- ) && (
-
- Adding an agent also adds it to{" "}
- {parentName ?? "the parent channel"}, with access to its
- history.
-
- )}
- {sessionMembers !== undefined &&
+ }
+ label={agent.name}
+ description={
+ admission === "channel"
+ ? "Adds to channel"
+ : admission === "session-and-channel"
+ ? "Adds to session and channel"
+ : admission === "session"
+ ? "Adds to session"
+ : undefined
+ }
+ />
+
+ );
+ })}
+
+ {allowed !== undefined &&
+ sessionMembers === undefined &&
+ agents.identities.some(
+ (agent) => !allowed.includes(agent.pubkey),
+ ) && (
+
+ Adding an agent also adds it to{" "}
+ {parentName ?? "the parent channel"}, with access to its history.
+
+ )}
+ {sessionMembers !== undefined &&
+ agents.identities.some(
+ (agent) =>
+ agentAdmission(
+ agent.pubkey,
+ allowed,
+ sessionMembers,
+ parentMembers,
+ ) !== undefined,
+ ) && (
+
+ Adding an agent gives it access to this session’s history
+ {parentMembers !== undefined &&
agents.identities.some(
(agent) =>
agentAdmission(
@@ -190,48 +195,32 @@ export function AgentChoice({
allowed,
sessionMembers,
parentMembers,
- ) !== undefined,
- ) && (
-
- Adding an agent gives it access to this session’s history
- {parentMembers !== undefined &&
- agents.identities.some(
- (agent) =>
- agentAdmission(
- agent.pubkey,
- allowed,
- sessionMembers,
- parentMembers,
- ) === "session-and-channel",
- )
- ? ` and may add it to ${parentName ?? "the parent channel"}, with access to that channel’s history`
- : ""}
- .
-
- )}
- {agents.status === "loading" && (
- Loading agents…
- )}
- {agents.status === "ready" && !agents.identities.length && (
- No agents available in this community.
- )}
- {agents.status === "unavailable" && (
-
- Your agent library isn’t available on this connection.
-
- )}
- {(agents.status === "error" || !!agents.error) && (
- void library.refresh()}
- >
- Retry agent list
-
- )}
-
-
-
-
+ ) === "session-and-channel",
+ )
+ ? ` and may add it to ${parentName ?? "the parent channel"}, with access to that channel’s history`
+ : ""}
+ .
+
+ )}
+ {agents.status === "loading" && (
+ Loading agents…
+ )}
+ {agents.status === "ready" && !agents.identities.length && (
+
+ No agents available in this community.
+
+ )}
+ {agents.status === "unavailable" && (
+
+ Your agent library isn’t available on this connection.
+
+ )}
+ {(agents.status === "error" || !!agents.error) && (
+ void library.refresh()}>
+ Retry agent list
+
+ )}
+
+
);
}
diff --git a/src/features/sessions/Sessions.module.css b/src/features/sessions/Sessions.module.css
index 02735adb8..4b7c9fe12 100644
--- a/src/features/sessions/Sessions.module.css
+++ b/src/features/sessions/Sessions.module.css
@@ -105,32 +105,10 @@
min-width: 0;
max-width: min(240px, 45vw);
}
-.agentTrigger .agentAvatar,
-.agentOption .agentAvatar {
+.agentTrigger .agentAvatar {
width: 24px;
height: 24px;
}
-.agentPositioner {
- z-index: var(--layer-popover);
-}
-.agentPositioner .agentMenu {
- position: relative;
- max-width: min(340px, var(--available-width));
- max-height: min(360px, var(--available-height));
- font-size: var(--text-body-sm);
- outline: none;
-}
-.agentOption {
- outline: none;
-}
-.agentOption[data-highlighted] {
- background: var(--completion-highlight);
-}
-.agentCheck {
- margin-left: auto;
- padding-left: var(--space-2);
-}
-
.agentName {
overflow: hidden;
text-overflow: ellipsis;
diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md
index b9ca09cc9..b98ec99f8 100644
--- a/src/shared/design-system/DESIGN.md
+++ b/src/shared/design-system/DESIGN.md
@@ -107,6 +107,7 @@ Buzz is a place where people build together and bring their agents into the room
- **No page-wide gradient behind documentation or dense reading.** The gradient is the product's backdrop for chrome and panels. Behind a column of prose it fights the text and makes contrast position-dependent — such surfaces sit on `bg-panel`.
- **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary.
- **Floating controls share one outer material.** Menus, selects, popovers, and preview cards use the opaque `floating-surface`: floating fill, primary boundary, panel radius, and graduated lift. Each component still owns its content padding and interaction behavior; sharing the container does not imply that a preview behaves like a menu.
+- **Floating rows need their own hover contrast.** Menu, select, and popover activity rows use `affordance-floating-hover` (neutral 3 in light mode, neutral 7 in dark). Supporting text becomes standard text on highlight so it stays readable. Selection marks remain independent of hover. Small action menus and compact account popovers use 10px `radius-row` outer corners with a 4px list inset and 8px inner rows (80% of the outer radius). Their hover uses `affordance-subtle-hover` with immediate feedback. Default/wide menus, content popovers, pickers, dialogs, and alert dialogs retain 24px `radius-panel` outer corners. Choose compact explicitly for short action lists, never automatically from viewport width.
- **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode.
- **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged.
- **Light comes from one direction, and every glass surface agrees on it.** A glass rim is bright along the lit edge and dimmer on the opposite one; that is what makes it read as a material rather than an outline. Two surfaces lit from different directions in the same view look like a mistake.
@@ -224,6 +225,38 @@ designer-requested blur from 4px to zero is a narrow exception to the general
no-blur-animation rule. Base UI owns transition presence and dismissal; keyboard
navigation and reduced motion remove the transition, movement, and blur.
+### Menus, popovers, and choice rows
+
+Use Menu for actions and lightweight choices, Popover for supporting content or
+short forms, and Select/Combobox for form values. Both anchored surfaces reuse
+`floating-surface`, viewport collision handling, and the shared popover layer.
+Features own their data, callbacks and save/cancel behavior; Base UI owns focus,
+keyboard navigation, positioning and dismissal.
+
+Menu group labels belong inside MenuGroup. Selection checks sit at the trailing
+edge; the pointer/keyboard highlight is independent of that persistent selection.
+Keep the parent row highlighted while its submenu is open. Use `tone="danger"`
+for destructive actions and MenuNote for explanatory or status copy outside the
+keyboard item list. Long lists scroll inside the popup.
+
+ChoiceRow arranges a label, wrapping description, optional artwork and trailing
+metadata. It adds no second click target or tab stop. Keep its slots non-interactive
+and let the containing item own state and padding. Use the small shared avatar
+for identity choices, retaining human/agent shapes.
+
+PopoverPopup uses 16px content padding, or `padding="list"` when its rows own their
+spacing. Use `size="compact"` with list padding for short account/action surfaces: 14rem width and 10px corners. `MenuPopup size="compact"` uses the same corner, inset, row and hover treatment for short action lists. Content and wide popovers retain 24px corners. Name it with PopoverTitle or aria-label; PopoverDescription connects
+supporting copy. Hover opening is optional and remains configured by its feature.
+Use `padding="none"` for an embedded picker that owns its internal spacing, such as emoji/GIF content.
+Menus and popovers use a quicker version of the form dropdown motion: 75ms entry
+and 60ms exit (half the state/fast duration tokens), a 2px offset and blur-to-sharp
+opacity fade. Movement uses
+easing-settle; opacity and filter use easing-state. The offset follows the actual
+placement side toward the trigger, including collision flips and nested menus.
+This extends the designer-requested blur exception to these anchored surfaces.
+Keyboard navigation and reduced motion remove transitions, movement, and blur. The Just Design Menu, Popover and ChoiceRow pages
+show these contracts and their compositions.
+
## Compositions
Dialog composes a Base UI modal with a shared title, optional description, body,
@@ -528,3 +561,15 @@ it is the rule a generated theme is measured against.
Phosphor is the only general icon family. Import named icons from `icons/index.ts`, which re-exports individual upstream modules. Add exports as needed; no approval list. SVG-only widgets use individual assets through `icons/svg.ts`. Do not import the upstream packages elsewhere or reintroduce other icon libraries. All six native weights remain designer choices: no size-to-weight or selection-to-fill rules. For chat and conversation metaphors, prefer the rounded `ChatCircle` family (including `ChatsCircle`) over square or teardrop variants; choose the matching dots, text, or slash variant when the meaning requires it. Keep accessible names on controls and decorative artwork hidden from assistive technology.
OneDrive is a designer-approved custom brand mark: its complete outline is recreated on Phosphor’s square canvas, uses the same current-color and sizing behavior, and stays in the shared icon gateway. It does not permit another general icon library.
+
+### Picker search and choices
+
+Mention and media pickers opt into `SearchField variant="capsule"`. Its shared
+`search-field.css` recipe also styles Emoji Mart inside its shadow root: body-sm
+typography, pill radius, standard panel fill, Phosphor icons, and a 32px clear
+action. Other SearchField callers retain the default field treatment. Scrolling
+picker results use the opt-in `buzz-thin-scrollbar` native scrollbar recipe.
+
+Mention choices use `NavigationItem variant="option"` with 8px padding and
+immediate hover/focus feedback. The picker owns arrow-key navigation and exact
+identity selection; rows retain ordinary button semantics.
diff --git a/src/shared/design-system/icons/svg.ts b/src/shared/design-system/icons/svg.ts
index 4e51ef008..d04c34ee7 100644
--- a/src/shared/design-system/icons/svg.ts
+++ b/src/shared/design-system/icons/svg.ts
@@ -11,7 +11,12 @@ import flag from "@phosphor-icons/core/assets/regular/flag.svg?raw";
import asterisk from "@phosphor-icons/core/assets/regular/asterisk.svg?raw";
import x_circle from "@phosphor-icons/core/assets/regular/x-circle.svg?raw";
+import x from "@phosphor-icons/core/assets/regular/x.svg?raw";
+import magnifying_glass from "@phosphor-icons/core/assets/regular/magnifying-glass.svg?raw";
+
export const pickerIcons = {
+ x,
+ "magnifying-glass": magnifying_glass,
clock: clock,
smiley: smiley,
"paw-print": paw_print,
diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css
index 9c2a9c540..866d27268 100644
--- a/src/shared/design-system/styles/components.css
+++ b/src/shared/design-system/styles/components.css
@@ -688,6 +688,17 @@
color: var(--text-standard);
}
+ .navigation-item[data-variant="option"] {
+ padding: var(--space-2);
+ transition: none;
+ }
+ .navigation-item[data-variant="option"]:focus-visible {
+ background: var(--affordance-floating-hover);
+ --text-metadata: var(--text-standard);
+ --text-subtle: var(--text-standard);
+ outline-offset: -2px;
+ }
+
.navigation-item[data-inset] {
padding-left: calc(var(--space-control-inset) + var(--space-4));
}
@@ -723,26 +734,21 @@
@layer components {
/* The positioned portal owns the stacking context, not its popup child. */
- .buzz-menu-positioner {
+ .buzz-menu-positioner,
+ .buzz-popover-positioner {
z-index: var(--layer-popover);
}
.buzz-menu-popup {
min-width: 11.5rem;
- max-width: min(20rem, calc(100vw - var(--space-4)));
- max-height: var(--available-height);
+ max-width: min(20rem, var(--available-width), calc(100vw - var(--space-4)));
+ max-height: min(24rem, var(--available-height));
overflow-y: auto;
padding: var(--space-1);
@apply floating-surface;
color: var(--text-standard);
- transform-origin: var(--transform-origin);
- transition:
- opacity var(--duration-state) var(--easing-state),
- transform var(--duration-state) var(--easing-state);
}
- .buzz-menu-popup[data-starting-style],
- .buzz-menu-popup[data-ending-style] {
- opacity: 0;
- transform: scale(0.98);
+ .buzz-menu-popup[data-size="wide"] {
+ max-width: min(28rem, var(--available-width), calc(100vw - var(--space-4)));
}
.buzz-menu-item {
display: flex;
@@ -761,8 +767,18 @@
/* Keep the ring inside the scrollable popup's clipping edge. */
outline-offset: -2px;
}
- .buzz-menu-item[data-highlighted] {
- background: var(--affordance-subtle-hover);
+ /* Floating rows need more separation than controls on a darker panel.
+ Supporting copy follows the brighter fill so it remains readable. */
+ .buzz-menu-item[data-highlighted],
+ .buzz-menu-item[data-popup-open],
+ .buzz-select-option[data-highlighted],
+ .buzz-popover-popup .navigation-item:hover:not([data-selected]) {
+ --text-subtle: var(--text-standard);
+ --text-metadata: var(--text-standard);
+ background: var(--affordance-floating-hover);
+ }
+ .buzz-menu-item[data-tone="danger"] {
+ color: var(--text-danger);
}
.buzz-menu-item[data-disabled] {
color: var(--text-unavailable);
@@ -777,17 +793,16 @@
color: var(--text-subtle);
}
.buzz-menu-choice-label {
- display: contents;
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-row-gap);
+ overflow-wrap: anywhere;
}
.buzz-menu-item-indicator:not([data-checked]) {
visibility: hidden;
}
- .buzz-menu-radio-dot {
- width: 0.375rem;
- height: 0.375rem;
- border-radius: var(--radius-pill);
- background: currentColor;
- }
.buzz-menu-submenu-arrow,
.buzz-menu-trailing {
margin-inline-start: auto;
@@ -803,9 +818,144 @@
margin: var(--space-1) 0;
background: var(--border-standard);
}
+ .buzz-menu-note {
+ margin: 0;
+ padding: var(--space-2);
+ color: var(--text-subtle);
+ overflow-wrap: anywhere;
+ }
+ .buzz-popover-popup {
+ /* Persistent navigation selection must remain visible on the raised fill. */
+ --affordance-selected: var(--affordance-subtle-hover);
+ @apply floating-surface;
+ width: 20rem;
+ max-width: min(var(--available-width), calc(100vw - var(--space-4)));
+ max-height: var(--available-height);
+ overflow-y: auto;
+ padding: var(--space-4);
+ color: var(--text-standard);
+ }
+ .buzz-popover-popup[data-size="compact"] {
+ width: 14rem;
+ }
+ /* Short action surfaces pair 10px outer corners with 8px inner rows.
+ Content surfaces keep the panel recipe, regardless of viewport width. */
+ .buzz-menu-popup[data-size="compact"],
+ .buzz-popover-popup[data-size="compact"] {
+ --affordance-floating-hover: var(--affordance-subtle-hover);
+ border-radius: var(--radius-row);
+ }
+ .buzz-menu-popup[data-size="compact"] .buzz-menu-item,
+ .buzz-popover-popup[data-size="compact"] .navigation-item {
+ border-radius: calc(var(--radius-row) * 0.8);
+ transition: none;
+ }
+ .buzz-popover-popup[data-size="compact"] .navigation-item:focus-visible {
+ background: var(--affordance-floating-hover);
+ outline-offset: -2px;
+ }
+ .buzz-popover-popup[data-size="wide"] {
+ width: 24rem;
+ }
+ .buzz-popover-popup[data-padding="list"] {
+ padding: var(--space-1);
+ }
+ .buzz-popover-popup[data-padding="none"] {
+ padding: 0;
+ }
+ .buzz-popover-title {
+ margin: 0;
+ }
+ .buzz-popover-description {
+ margin: var(--space-2) 0 0;
+ color: var(--text-subtle);
+ }
+ .buzz-choice-row {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-row-gap);
+ }
+ .buzz-choice-row-leading {
+ display: flex;
+ flex: 0 0 auto;
+ }
+ .buzz-choice-row-content {
+ display: grid;
+ flex: 1;
+ min-width: 0;
+ gap: var(--space-half);
+ overflow-wrap: anywhere;
+ }
+ .buzz-choice-row-description,
+ .buzz-choice-row-trailing {
+ color: var(--text-subtle);
+ }
+ .buzz-choice-row-trailing {
+ flex: 0 0 auto;
+ }
+ .buzz-menu-item[data-disabled]
+ :is(
+ .buzz-choice-row-description,
+ .buzz-choice-row-trailing,
+ .buzz-menu-icon
+ ) {
+ color: inherit;
+ }
+ /* Use the dropdown unblur and offset at half duration and distance. Move from
+ the actual placement side, including flipped popovers and submenus. */
+ .buzz-menu-popup,
+ .buzz-popover-popup {
+ opacity: 1;
+ transform: none;
+ filter: blur(0);
+ transform-origin: var(--transform-origin);
+ transition:
+ opacity calc(var(--duration-state) / 2) var(--easing-state),
+ transform calc(var(--duration-state) / 2) var(--easing-settle),
+ filter calc(var(--duration-state) / 2) var(--easing-state);
+ }
+ :is(.buzz-menu-popup, .buzz-popover-popup):where(
+ [data-starting-style],
+ [data-ending-style]
+ ) {
+ opacity: 0;
+ filter: blur(var(--space-half));
+ transform: translateY(calc(-1 * var(--space-half)));
+ }
+ :is(.buzz-menu-popup, .buzz-popover-popup):where([data-side="top"]):where(
+ [data-starting-style],
+ [data-ending-style]
+ ) {
+ transform: translateY(var(--space-half));
+ }
+ :is(.buzz-menu-popup, .buzz-popover-popup):where([data-side="right"]):where(
+ [data-starting-style],
+ [data-ending-style]
+ ) {
+ transform: translateX(calc(-1 * var(--space-half)));
+ }
+ :is(.buzz-menu-popup, .buzz-popover-popup):where([data-side="left"]):where(
+ [data-starting-style],
+ [data-ending-style]
+ ) {
+ transform: translateX(var(--space-half));
+ }
+ :is(.buzz-menu-popup, .buzz-popover-popup):where([data-ending-style]) {
+ transition-duration: calc(var(--duration-fast) / 2);
+ }
+ html[data-keyboard-navigation] :is(.buzz-menu-popup, .buzz-popover-popup) {
+ transition: none;
+ transform: none;
+ filter: none;
+ }
@media (prefers-reduced-motion: reduce) {
- .buzz-menu-popup {
- transition-duration: 0ms;
+ .buzz-menu-popup,
+ .buzz-popover-popup {
+ transition: none;
+ transform: none;
+ filter: none;
}
}
}
@@ -876,7 +1026,6 @@
cursor: default;
}
.buzz-select-option[data-highlighted] {
- background: var(--affordance-subtle-hover);
outline: none;
}
.buzz-select-option[data-selected] {
diff --git a/src/shared/design-system/styles/materials.css b/src/shared/design-system/styles/materials.css
index cdf3cb8ed..d440d5c21 100644
--- a/src/shared/design-system/styles/materials.css
+++ b/src/shared/design-system/styles/materials.css
@@ -52,8 +52,8 @@
/* An opaque surface above a panel: menus, selects, popovers, and previews.
The outer material is intentionally one utility so those components cannot
- drift in fill, boundary, radius, or elevation while retaining independent
- content layout and behavior. */
+ drift in fill, boundary, or elevation while retaining independent content
+ layout and behavior. Compact action surfaces override the default radius. */
@utility floating-surface {
border: 1px solid var(--border-standard);
border-radius: var(--radius-panel);
diff --git a/src/shared/design-system/styles/scrollbars.css b/src/shared/design-system/styles/scrollbars.css
new file mode 100644
index 000000000..6e40bdbb8
--- /dev/null
+++ b/src/shared/design-system/styles/scrollbars.css
@@ -0,0 +1,5 @@
+/* Opt-in native scrollbar treatment, also injected into Emoji Mart. */
+.buzz-thin-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-prominent) transparent;
+}
diff --git a/src/shared/design-system/styles/search-field.css b/src/shared/design-system/styles/search-field.css
new file mode 100644
index 000000000..b58431247
--- /dev/null
+++ b/src/shared/design-system/styles/search-field.css
@@ -0,0 +1,109 @@
+/* Shared by SearchField and the Emoji Mart shadow-root adapter. */
+.search-field {
+ box-sizing: border-box;
+ display: flex;
+ width: 100%;
+ min-height: var(--size-control);
+ align-items: center;
+ gap: var(--space-row-gap);
+ border: 1px solid var(--border-prominent);
+ border-radius: var(--radius-pill);
+ padding: 0 var(--space-control-inset);
+ background: var(--surface-panel);
+ color: var(--text-metadata);
+ font-family: var(--font-sans);
+ font-size: var(--text-body-sm);
+ font-weight: var(--text-body-sm--font-weight);
+ line-height: var(--text-body-sm--line-height);
+ letter-spacing: var(--text-body-sm--letter-spacing);
+}
+
+.search-field:has([data-search-clear]) {
+ padding-right: var(--space-1);
+}
+
+.search-field input[type="search"] {
+ width: 0;
+ min-width: 0;
+ height: auto;
+ flex: 1;
+ order: 1;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ background: transparent;
+ color: var(--text-standard);
+ font: inherit;
+ letter-spacing: inherit;
+ appearance: none;
+ transition: none;
+}
+
+.search-field input[type="search"]:focus {
+ outline: none;
+ box-shadow: none;
+ background: transparent;
+}
+
+html[data-keyboard-navigation] .search-field:has(input:focus-visible),
+:host([data-keyboard-navigation]) .search-field:has(input:focus-visible) {
+ /* Match the temporary global focus rule, including the shadow-root host. */
+ outline: none;
+}
+
+.search-field input[type="search"]::placeholder {
+ color: var(--text-metadata);
+ opacity: 1;
+}
+
+.search-field input::-webkit-search-cancel-button {
+ display: none;
+}
+
+.search-field > svg {
+ flex-shrink: 0;
+}
+
+.search-field [data-search-clear] {
+ position: static;
+ display: grid;
+ place-items: center;
+ flex: 0 0 var(--size-control-sm);
+ order: 2;
+ width: var(--size-control-sm);
+ height: var(--size-control-sm);
+ min-height: 0;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--text-metadata);
+ cursor: pointer;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .search-field [data-search-clear]:hover:not(:disabled) {
+ background: var(--affordance-subtle-hover);
+ }
+}
+
+.search-field [data-search-clear]:active:not(:disabled) {
+ background: var(--affordance-subtle-pressed);
+}
+
+html[data-keyboard-navigation] .search-field [data-search-clear]:focus-visible,
+:host([data-keyboard-navigation])
+ .search-field
+ [data-search-clear]:focus-visible {
+ outline: none;
+}
+
+.search-field:has(input:disabled) {
+ opacity: 0.5;
+}
+.search-field:has(input[aria-invalid="true"]) {
+ border-color: var(--border-danger);
+}
diff --git a/src/shared/design-system/styles/tokens.css b/src/shared/design-system/styles/tokens.css
index 71584dfd5..db1048e34 100644
--- a/src/shared/design-system/styles/tokens.css
+++ b/src/shared/design-system/styles/tokens.css
@@ -511,6 +511,7 @@
--affordance-subtle-hover: var(--neutral-3);
--affordance-subtle-pressed: var(--neutral-4);
--affordance-panel-hover: var(--neutral-3);
+ --affordance-floating-hover: var(--neutral-3);
--affordance-selected: var(--neutral-3);
--affordance-disabled: var(--neutral-3);
--affordance-danger: var(--red-3);
@@ -555,6 +556,7 @@
--affordance-subtle-hover: var(--neutral-6);
--affordance-subtle-pressed: var(--neutral-7);
--affordance-panel-hover: var(--neutral-4);
+ --affordance-floating-hover: var(--neutral-7);
--affordance-selected: var(--neutral-5);
--affordance-disabled: var(--neutral-3);
--affordance-danger: var(--red-3);
@@ -753,6 +755,7 @@
--color-affordance-subtle-hover: var(--affordance-subtle-hover);
--color-affordance-subtle-pressed: var(--affordance-subtle-pressed);
--color-affordance-panel-hover: var(--affordance-panel-hover);
+ --color-affordance-floating-hover: var(--affordance-floating-hover);
--color-affordance-selected: var(--affordance-selected);
--color-affordance-disabled: var(--affordance-disabled);
--color-affordance-danger: var(--affordance-danger);
diff --git a/src/shared/design-system/tokens/registry.ts b/src/shared/design-system/tokens/registry.ts
index 0c10d5d8b..7048501b0 100644
--- a/src/shared/design-system/tokens/registry.ts
+++ b/src/shared/design-system/tokens/registry.ts
@@ -498,6 +498,13 @@ export const ROLE_GROUPS: RoleGroup[] = [
status: "proposed",
owner: "Morgan",
},
+ {
+ token: "bg-affordance-floating-hover",
+ variable: "--affordance-floating-hover",
+ pointsAt: "neutral-3 light / neutral-7 dark",
+ use: "Highlighted rows on floating surfaces. Pair with standard text, including supporting copy, to preserve readability in dark mode.",
+ status: "core",
+ },
{
token: "bg-affordance-selected",
variable: "--affordance-selected",
diff --git a/src/shared/design-system/ui/ChoiceRow.tsx b/src/shared/design-system/ui/ChoiceRow.tsx
new file mode 100644
index 000000000..8de0c49d6
--- /dev/null
+++ b/src/shared/design-system/ui/ChoiceRow.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+
+/** Row content only. Menu/Select/Combobox owns selection and keyboard behavior.
+ * Keep leading and trailing content non-interactive; the parent is the one action. */
+export function ChoiceRow({
+ label,
+ description,
+ leading,
+ trailing,
+}: {
+ label: ReactNode;
+ description?: ReactNode;
+ leading?: ReactNode;
+ trailing?: ReactNode;
+}) {
+ return (
+
+ {leading && (
+
+ {leading}
+
+ )}
+
+ {label}
+ {description && (
+ <>
+ {" "}
+
+ {description}
+
+ >
+ )}
+
+ {trailing && (
+ <>
+ {" "}
+
+ {trailing}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/shared/design-system/ui/Menu.test.tsx b/src/shared/design-system/ui/Menu.test.tsx
index 7158a4dd1..f4dc23e25 100644
--- a/src/shared/design-system/ui/Menu.test.tsx
+++ b/src/shared/design-system/ui/Menu.test.tsx
@@ -11,6 +11,7 @@ import {
import userEvent from "@testing-library/user-event";
import { StrictMode, useState } from "react";
import { Button } from "./Button";
+import { ChoiceRow } from "./ChoiceRow";
import {
ContextMenuRoot,
ContextMenuTrigger,
@@ -169,3 +170,44 @@ test("context menus reuse the shared popup and action boundary", async () => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
);
});
+
+test("rich choices retain their names and selected state while disabled choices reject activation", async () => {
+ const user = userEvent.setup();
+ const change = vi.fn();
+ render(
+
+ Agents} />
+
+
+
+ S}
+ />
+
+
+ A}
+ />
+
+
+
+ ,
+ );
+ await user.click(screen.getByRole("button", { name: "Agents" }));
+ await screen.findByRole("menu");
+ expect(
+ screen.getByRole("menuitemradio", {
+ name: "Studio Already in this session",
+ }),
+ ).toBeChecked();
+ const unavailable = screen.getByRole("menuitemradio", {
+ name: "Archive Unavailable",
+ });
+ await user.click(unavailable);
+ expect(change).not.toHaveBeenCalled();
+ expect(unavailable).toHaveAttribute("aria-disabled", "true");
+});
diff --git a/src/shared/design-system/ui/Menu.tsx b/src/shared/design-system/ui/Menu.tsx
index 0346d284a..d650335fe 100644
--- a/src/shared/design-system/ui/Menu.tsx
+++ b/src/shared/design-system/ui/Menu.tsx
@@ -15,6 +15,7 @@ export type MenuPosition = Pick<
| "align"
| "alignOffset"
| "anchor"
+ | "collisionPadding"
| "collisionAvoidance"
| "side"
| "sideOffset"
@@ -24,6 +25,8 @@ export type MenuPosition = Pick<
type PopupProps = Omit, "className"> &
MenuPosition & {
children: ReactNode;
+ /** Compact is for short action lists; larger choices retain panel corners. */
+ size?: "compact" | "default" | "wide";
};
function PositionedPopup({
@@ -32,6 +35,8 @@ function PositionedPopup({
anchor,
children,
collisionAvoidance,
+ collisionPadding = 8,
+ size = "default",
side = "bottom",
sideOffset = 4,
sticky,
@@ -45,6 +50,7 @@ function PositionedPopup({
alignOffset={alignOffset}
anchor={anchor}
collisionAvoidance={collisionAvoidance}
+ collisionPadding={collisionPadding}
side={side}
sideOffset={sideOffset}
sticky={sticky}
@@ -52,6 +58,7 @@ function PositionedPopup({
{children}
@@ -74,10 +81,14 @@ export function MenuSubmenuPopup(props: PopupProps) {
return ;
}
-type ItemProps = Omit, "className">;
+type ItemProps = Omit, "className"> & {
+ tone?: "default" | "danger";
+};
-export function MenuItem(props: ItemProps) {
- return ;
+export function MenuItem({ tone = "default", ...props }: ItemProps) {
+ return (
+
+ );
}
type LinkItemProps = Omit<
@@ -118,13 +129,13 @@ type CheckboxItemProps = Omit<
export function MenuCheckboxItem({ children, ...props }: CheckboxItemProps) {
return (
+ {children}
-
+
- {children}
);
}
@@ -137,13 +148,13 @@ type RadioItemProps = Omit<
export function MenuRadioItem({ children, ...props }: RadioItemProps) {
return (
+ {children}
-
+
- {children}
);
}
@@ -171,3 +182,8 @@ export function MenuIcon({ children }: { children: ReactNode }) {
export function MenuTrailing({ children }: { children: ReactNode }) {
return {children} ;
}
+
+/** Supporting context and status text, outside the menu's keyboard item list. */
+export function MenuNote(props: Omit, "className">) {
+ return
;
+}
diff --git a/src/shared/design-system/ui/NavigationItem.tsx b/src/shared/design-system/ui/NavigationItem.tsx
index 0431e8023..e665432bd 100644
--- a/src/shared/design-system/ui/NavigationItem.tsx
+++ b/src/shared/design-system/ui/NavigationItem.tsx
@@ -15,7 +15,7 @@ export function NavigationItem({
trailing?: ReactNode;
selected?: boolean;
inset?: boolean;
- variant?: "row" | "pill";
+ variant?: "row" | "pill" | "option";
} & Omit, "className" | "children">) {
return (
{
+ const user = userEvent.setup();
+ const save = vi.fn();
+ function Example() {
+ const [value, setValue] = useState("Studio");
+ return (
+
+ Edit name} />
+
+ Workspace name
+ A name your team recognizes.
+
+
+
+ );
+ }
+ render(
+
+
+ ,
+ );
+ const trigger = screen.getByRole("button", { name: "Edit name" });
+ await user.click(trigger);
+ const popup = await screen.findByRole("dialog", { name: "Workspace name" });
+ expect(popup).toHaveAccessibleDescription("A name your team recognizes.");
+ await user.clear(screen.getByRole("textbox", { name: "Name" }));
+ await user.type(screen.getByRole("textbox", { name: "Name" }), "Research");
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(save).toHaveBeenCalledExactlyOnceWith("Research");
+ await user.click(screen.getByRole("button", { name: "Done" }));
+ await waitFor(() => expect(popup).not.toBeInTheDocument());
+ expect(save).toHaveBeenCalledTimes(1);
+ await user.click(trigger);
+ expect(await screen.findByRole("textbox", { name: "Name" })).toHaveValue(
+ "Research",
+ );
+});
+
+test("controlled dismissal reports closing and disabled triggers stay closed", async () => {
+ const user = userEvent.setup();
+ const change = vi.fn();
+ function Example() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ {
+ setOpen(next);
+ change(next);
+ }}
+ >
+ Details} />
+
+ Close} />
+
+
+
+ Unavailable} />
+
+
+ >
+ );
+ }
+ render( );
+ await user.click(screen.getByRole("button", { name: "Unavailable" }));
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Details" }));
+ await user.click(await screen.findByRole("button", { name: "Close" }));
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
+ );
+ expect(change.mock.calls).toEqual([[true], [false]]);
+});
diff --git a/src/shared/design-system/ui/Popover.tsx b/src/shared/design-system/ui/Popover.tsx
new file mode 100644
index 000000000..0f85a18bd
--- /dev/null
+++ b/src/shared/design-system/ui/Popover.tsx
@@ -0,0 +1,86 @@
+import { Popover as BasePopover } from "@base-ui/react/popover";
+import type { ComponentProps } from "react";
+
+export const PopoverRoot = BasePopover.Root;
+export const PopoverTrigger = BasePopover.Trigger;
+export const PopoverClose = BasePopover.Close;
+
+// The feature retains its content and state; this frame owns material, motion,
+// viewport constraints and stacking. Base UI owns focus, dismissal and placement.
+type PopoverPopupProps = Omit<
+ ComponentProps,
+ "className"
+> &
+ Pick<
+ ComponentProps,
+ | "side"
+ | "align"
+ | "sideOffset"
+ | "alignOffset"
+ | "collisionPadding"
+ | "collisionAvoidance"
+ | "anchor"
+ | "sticky"
+ > & {
+ /** Compact account/action surfaces use tighter corners as well as width. */
+ size?: "compact" | "default" | "wide";
+ /** Embedded pickers own their internal spacing. */
+ padding?: "content" | "list" | "none";
+ };
+
+export function PopoverPopup({
+ side = "bottom",
+ align = "start",
+ sideOffset = 8,
+ alignOffset,
+ collisionPadding = 8,
+ collisionAvoidance,
+ anchor,
+ sticky,
+ size = "default",
+ padding = "content",
+ ...props
+}: PopoverPopupProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function PopoverTitle(
+ props: Omit, "className">,
+) {
+ return (
+
+ );
+}
+
+export function PopoverDescription(
+ props: Omit, "className">,
+) {
+ return (
+
+ );
+}
diff --git a/src/shared/design-system/ui/SearchField.tsx b/src/shared/design-system/ui/SearchField.tsx
index b7d0dc8fe..145f07874 100644
--- a/src/shared/design-system/ui/SearchField.tsx
+++ b/src/shared/design-system/ui/SearchField.tsx
@@ -1,3 +1,4 @@
+import "../styles/search-field.css";
import { Field } from "./Field";
import { InputGroup } from "./InputGroup";
import { Input } from "@base-ui/react/input";
@@ -11,11 +12,13 @@ export function SearchField({
label = "Search",
placeholder = "Search",
inputRef,
+ variant = "default",
description,
error,
...inputProps
}: {
inputRef?: Ref;
+ variant?: "default" | "capsule";
description?: ReactNode;
error?: ReactNode;
value: string;
@@ -27,6 +30,36 @@ export function SearchField({
"value" | "onValueChange" | "className" | "ref" | "render" | "type"
>) {
const localRef = useRef(null);
+ const clear = value ? (
+ }
+ size="sm"
+ disabled={inputProps.disabled || inputProps.readOnly}
+ onClick={() => {
+ onValueChange("");
+ localRef.current?.focus();
+ }}
+ />
+ ) : null;
+ const input = (
+ {
+ localRef.current = node;
+ if (typeof inputRef === "function") return inputRef(node);
+ if (inputRef) inputRef.current = node;
+ }}
+ type="search"
+ value={value}
+ onValueChange={onValueChange}
+ placeholder={placeholder}
+ />
+ );
+ const icon = ;
return (
- }
- trailing={
- value ? (
- }
- size="sm"
- disabled={inputProps.disabled || inputProps.readOnly}
- onClick={() => {
- onValueChange("");
- localRef.current?.focus();
- }}
- />
- ) : null
- }
- >
- {
- localRef.current = node;
- if (typeof inputRef === "function") return inputRef(node);
- if (inputRef) inputRef.current = node;
- }}
- type="search"
- value={value}
- onValueChange={onValueChange}
- placeholder={placeholder}
- />
-
+ {variant === "capsule" ? (
+
+ {icon}
+ {input}
+ {clear}
+
+ ) : (
+
+ {input}
+
+ )}
);
}
diff --git a/src/shared/design-system/ui/controls.test.tsx b/src/shared/design-system/ui/controls.test.tsx
index 65e3afb0a..8892f02c3 100644
--- a/src/shared/design-system/ui/controls.test.tsx
+++ b/src/shared/design-system/ui/controls.test.tsx
@@ -143,31 +143,35 @@ test("radio and checkbox labels change the actual form values while disabled cho
).toEqual({ mode: "light", summary: "yes" });
});
-test("search forwards keyboard events and ref, and clearing restores input focus", async () => {
- const user = userEvent.setup();
- const ref = createRef();
- const onKeyDown = vi.fn();
- function Example() {
- const [value, setValue] = useState("draft");
- return (
-
- );
- }
- render( );
- const input = screen.getByRole("searchbox", { name: "Notes" });
- expect(ref.current).toBe(input);
- await user.click(screen.getByRole("button", { name: "Clear notes" }));
- expect(input).toHaveValue("");
- expect(input).toHaveFocus();
- await user.keyboard("{Escape}");
- expect(onKeyDown).toHaveBeenCalled();
-});
+test.each(["default", "capsule"] as const)(
+ "%s search forwards keyboard events and ref, and clearing restores input focus",
+ async (variant) => {
+ const user = userEvent.setup();
+ const ref = createRef();
+ const onKeyDown = vi.fn();
+ function Example() {
+ const [value, setValue] = useState("draft");
+ return (
+
+ );
+ }
+ render( );
+ const input = screen.getByRole("searchbox", { name: "Notes" });
+ expect(ref.current).toBe(input);
+ await user.click(screen.getByRole("button", { name: "Clear notes" }));
+ expect(input).toHaveValue("");
+ expect(input).toHaveFocus();
+ await user.keyboard("{Escape}");
+ expect(onKeyDown).toHaveBeenCalled();
+ },
+);
test.each([false, true])(
"native reset restores uncontrolled choices and form values (external form: %s)",
diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts
index 8b4d701f5..2212e8ac2 100644
--- a/src/shared/design-system/ui/registry.ts
+++ b/src/shared/design-system/ui/registry.ts
@@ -460,7 +460,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
name: "SearchField",
purpose: "A compact filter field with a search cue and clear action.",
behavior: "Base UI Field and Input",
- variants: ["default"],
+ variants: ["default", "capsule"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
@@ -487,7 +487,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
purpose:
"A selectable destination row or pill with optional icon and metadata.",
behavior: "Base UI Button",
- variants: ["row", "pill", "inset", "selected"],
+ variants: ["row", "pill", "option", "inset", "selected"],
status: "proposed",
collection: "components",
owner: "desktop-new Messages",
@@ -529,6 +529,34 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
baseUi: [BASE_UI_PARTS.combobox],
composes: ["field", "icon-button"],
},
+ {
+ slug: "popover",
+ name: "Popover",
+ purpose:
+ "An anchored surface for supporting details, short forms, and interactive content.",
+ behavior:
+ "Base UI owns positioning, focus restoration and dismissal; feature code owns content and state",
+ variants: ["content", "list", "none", "compact", "wide"],
+ status: "proposed",
+ collection: "components",
+ source: "shared/design-system/ui/Popover.tsx",
+ baseUi: [BASE_UI_PARTS.popover],
+ composes: [],
+ },
+ {
+ slug: "choice-row",
+ name: "ChoiceRow",
+ purpose:
+ "A label, optional description, leading artwork and trailing detail inside a choice.",
+ behavior:
+ "Presentational content; the containing control owns selection, interaction and semantics",
+ variants: ["label", "description", "icon", "avatar", "trailing detail"],
+ status: "proposed",
+ collection: "components",
+ source: "shared/design-system/ui/ChoiceRow.tsx",
+ baseUi: [],
+ composes: [],
+ },
{
slug: "menu",
name: "Menu",
@@ -537,6 +565,7 @@ export const COMPONENTS: readonly ComponentDefinition[] = [
"Base UI owns positioning, dismissal, keyboard navigation, selection, and nested submenus",
variants: [
"actions",
+ "compact",
"links",
"checkbox choices",
"radio choices",
diff --git a/tests/browser/agent-activity.spec.mjs b/tests/browser/agent-activity.spec.mjs
index 53a170394..d21e6308d 100644
--- a/tests/browser/agent-activity.spec.mjs
+++ b/tests/browser/agent-activity.spec.mjs
@@ -1,5 +1,5 @@
import { test, expect } from "./fixture.mjs";
-import { open } from "./timeline.mjs";
+import { open, settle } from "./timeline.mjs";
import { npubEncode } from "nostr-tools/nip19";
import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools";
test.use({
@@ -414,7 +414,9 @@ test("profile activity opens the exact agent and originating channel before its
await expect(row).toBeVisible();
await expect(panel.getByText("1 observed working turn(s).")).toBeVisible();
await expect(
- panel.getByRole("button", { name: /acp_read|acp_write|session_resolved/ }),
+ panel.getByRole("button", {
+ name: /acp_read|acp_write|session_resolved/,
+ }),
).toHaveCount(0);
await row.click();
await expect(panel.locator("pre code")).toHaveText(expected.plaintext);
@@ -550,6 +552,21 @@ test("profile activity opens the exact agent and originating channel before its
await expect(
profile.getByRole("button", { name: "View activity", exact: true }),
).toHaveCount(0);
+ // Finish the reopened profile's focus handoff and timeline layout before
+ // starting read dwell; visible profile content alone proves neither.
+ await expect(
+ profile.getByRole("region", { name: "Profile details" }),
+ ).toBeFocused();
+ await settle(page);
+ const history = page.getByRole("region", {
+ name: "Channel message history",
+ });
+ await history.focus();
+ await expect(history).toBeFocused();
+ // Complete ordinary read dwell and publication before fixture teardown.
+ await expect
+ .poll(() => app.report.readPublications.length)
+ .toBeGreaterThan(1);
});
test.describe("thread activity", () => {
diff --git a/tests/browser/composer-attachments.spec.mjs b/tests/browser/composer-attachments.spec.mjs
index b0306fb74..306a98097 100644
--- a/tests/browser/composer-attachments.spec.mjs
+++ b/tests/browser/composer-attachments.spec.mjs
@@ -136,13 +136,16 @@ test("paperclip follows mentions and recipients, before the remaining tools", as
await mention.click();
const pubkey = await page.evaluate(() => window.mentionFixture.first);
await page
- .getByRole("region", { name: "Mention a member or agent" })
+ .getByRole("dialog", { name: "Mention a member or agent" })
.getByRole("button", { name: `Honey ${pubkey}`, exact: true })
.click();
const recipient = page
.getByRole("region", { name: "Explicit mentions" })
.getByRole("button");
await expect(recipient).toBeVisible();
+ await expect(
+ page.getByRole("dialog", { name: "Mention a member or agent" }),
+ ).toHaveCount(0);
await mention.focus();
for (const next of [recipient, attach, emoji]) {
await page.keyboard.press("Tab");
diff --git a/tests/browser/conversation.spec.mjs b/tests/browser/conversation.spec.mjs
index ac6f8d661..2eca696da 100644
--- a/tests/browser/conversation.spec.mjs
+++ b/tests/browser/conversation.spec.mjs
@@ -312,10 +312,10 @@ test("independent packed author consumer and native-installed contribution survi
await draft.fill("Channels draft");
await draft.focus();
await draft.evaluate((el) => el.setSelectionRange(2, 5));
- // Programmatic click avoids intentionally moving focus away from the editor.
- await page
- .getByRole("button", { name: "Rerender consumer 0" })
- .evaluate((el) => el.click());
+ // Request a render without a click outside the picker (which dismisses it).
+ await page.evaluate(() =>
+ window.dispatchEvent(new Event("conversation-fixture-rerender")),
+ );
await expect(
page.getByRole("button", { name: "Rerender consumer 1" }),
).toBeVisible();
@@ -330,9 +330,9 @@ test("independent packed author consumer and native-installed contribution survi
const search = page.getByRole("searchbox", { name: "Search" });
await search.fill("party");
const picker = await page.locator("em-emoji-picker").elementHandle();
- await page
- .getByRole("button", { name: "Rerender consumer 1" })
- .evaluate((el) => el.click());
+ await page.evaluate(() =>
+ window.dispatchEvent(new Event("conversation-fixture-rerender")),
+ );
await expect(
page.getByRole("button", { name: "Rerender consumer 2" }),
).toBeVisible();
diff --git a/tests/browser/design-system.spec.mjs b/tests/browser/design-system.spec.mjs
index dff1c89bd..4d91c2ba8 100644
--- a/tests/browser/design-system.spec.mjs
+++ b/tests/browser/design-system.spec.mjs
@@ -55,15 +55,26 @@ test("shared tokens reach app controls without history or chip overrides", async
chip.dataset.state = "resolved";
chip.textContent = "Reference";
probes.append(chip);
- for (const name of ["emojiPopover", "mentionPopover", "popup"]) {
- const selector = selectors.find((s) =>
- new RegExp(`\\._${name}_`).test(s),
- );
- if (!selector) throw new Error(`Missing popup style: ${name}`);
+ for (const name of ["buzz-popover-popup", "popup"]) {
const surface = document.createElement("div");
surface.id = `probe-${name}`;
- surface.className = selector.match(new RegExp(`\\.(_${name}_[\\w]+)`))[1];
- probes.append(surface);
+ if (name === "buzz-popover-popup") {
+ surface.className = name;
+ const positioner = document.createElement("div");
+ positioner.id = "probe-positioner";
+ positioner.className = "buzz-popover-positioner";
+ positioner.append(surface);
+ probes.append(positioner);
+ } else {
+ const selector = selectors.find((s) =>
+ new RegExp(`\\._${name}_`).test(s),
+ );
+ if (!selector) throw new Error(`Missing popup style: ${name}`);
+ surface.className = selector.match(
+ new RegExp(`\\.(_${name}_[\\w]+)`),
+ )[1];
+ probes.append(surface);
+ }
}
document.body.append(probes);
});
@@ -80,7 +91,7 @@ test("shared tokens reach app controls without history or chip overrides", async
--surface-popover: rgb(23, 45, 67);
--border-standard: rgb(45, 67, 89);
--radius-control: 13px;
- --radius-card: 19px;
+ --radius-panel: 19px;
--layer-popover: 1234;
}`,
});
@@ -100,15 +111,19 @@ test("shared tokens reach app controls without history or chip overrides", async
"background-color",
"rgb(11, 22, 33)",
);
- for (const name of ["emojiPopover", "mentionPopover", "popup"]) {
+ for (const name of ["buzz-popover-popup", "popup"]) {
const surface = page.locator(`#probe-${name}`);
await expect(surface).toHaveCSS("background-color", "rgb(23, 45, 67)");
await expect(surface).toHaveCSS("border-top-color", "rgb(45, 67, 89)");
await expect(surface).toHaveCSS(
"border-radius",
- name === "emojiPopover" ? "19px" : "13px",
+ name === "buzz-popover-popup" ? "19px" : "13px",
);
- await expect(surface).toHaveCSS("z-index", "1234");
+ await expect(
+ name === "buzz-popover-popup"
+ ? page.locator("#probe-positioner")
+ : surface,
+ ).toHaveCSS("z-index", "1234");
}
} finally {
await override.evaluate((node) => node.remove());
diff --git a/tests/browser/emoji.spec.mjs b/tests/browser/emoji.spec.mjs
index 360eb03e4..68fcedcf8 100644
--- a/tests/browser/emoji.spec.mjs
+++ b/tests/browser/emoji.spec.mjs
@@ -369,32 +369,43 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
await expect(toneMenu).toHaveCount(0);
await expect(skinTone).toBeFocused();
await search.focus();
- const searchIcon = page.locator(
- '[aria-label="Emoji picker"] svg[class*="sharedSearchIcon"]',
- );
+ const searchIcon = page.locator("em-emoji-picker .search .loupe svg");
await expectPhosphor(searchIcon, "magnifying-glass");
await expect(
- page.locator(
- '[aria-label="Emoji picker"] svg[class*="sharedSearchIcon"]:visible',
- ),
+ page.locator("em-emoji-picker .search .loupe svg:visible"),
).toHaveCount(1);
await expect(page.locator("em-emoji-picker .search .loupe")).toHaveCSS(
"visibility",
- "hidden",
+ "visible",
);
- await expect(search).toHaveCSS("height", "40px");
+ const searchField = page.locator("em-emoji-picker .search-field");
+ await expect(searchField).toHaveCSS("height", "40px");
await expect(search).toHaveCSS("margin-left", "0px");
await expect(search).toHaveCSS("margin-right", "0px");
- await expect(search).toHaveCSS("border-top-width", "1px");
- await expect(search).toHaveCSS("border-radius", "12px");
- await expect(search).toHaveCSS("background-color", "rgb(240, 240, 240)");
+ await expect(search).toHaveCSS("border-top-width", "0px");
+ await expect(searchField).toHaveCSS("border-radius", "159984px");
+ await expect(searchField).toHaveCSS(
+ "background-color",
+ "rgb(255, 255, 255)",
+ );
await expect(search).toHaveCSS("color", "rgb(0, 0, 0)");
await expect(search).toHaveCSS("outline-style", "none");
await expect(search).toHaveCSS("box-shadow", "none");
- await expect(search).toHaveCSS("font-size", "14px");
- await expect(search).toHaveCSS("border-top-color", "rgb(128, 128, 128)");
+ const expectSearchAlignment = async () => {
+ const offsets = await searchField.evaluate((field) => {
+ const bounds = field.getBoundingClientRect();
+ return [...field.querySelectorAll("input, svg")].map((element) => {
+ const rect = element.getBoundingClientRect();
+ return Math.abs(
+ rect.top + rect.height / 2 - bounds.top - bounds.height / 2,
+ );
+ });
+ });
+ for (const offset of offsets) expect(offset).toBeLessThan(1);
+ };
+ await expectSearchAlignment();
const surface = page.locator("em-emoji-picker #root");
- const region = page.getByRole("region", { name: "Emoji picker" });
+ const region = page.getByRole("dialog", { name: "Emoji picker" });
await expect(surface).toHaveAttribute("data-theme", "light");
await expect(region).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(region).toHaveCSS("border-radius", "24px");
@@ -406,7 +417,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
await expect(surface).toHaveCSS("width", "360px");
const initialRegion = await region.boundingBox();
const initialSurface = await surface.boundingBox();
- const searchGutters = await search.evaluate((input) => {
+ const searchGutters = await searchField.evaluate((input) => {
const searchBounds = input.getBoundingClientRect();
const rootBounds = input
.getRootNode()
@@ -419,8 +430,8 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
};
});
expect(searchGutters.left).toBeCloseTo(searchGutters.right, 1);
- expect(searchGutters.top).toBe(8);
- expect(searchGutters.left).toBe(8);
+ expect(searchGutters.top).toBe(12);
+ expect(searchGutters.left).toBe(12);
const searchBox = await search.boundingBox();
const searchIconBox = await searchIcon.boundingBox();
expect(searchIconBox.y + searchIconBox.height / 2).toBeCloseTo(
@@ -455,7 +466,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
).toBe(true);
await search.fill("party");
const emojiClear = page.locator("em-emoji-picker .search .delete");
- await expect(emojiClear).toHaveCSS("right", "4px");
+ await expect(emojiClear).toHaveCSS("position", "static");
await expect(emojiClear).toHaveCSS("width", "32px");
await expect(emojiClear).toHaveCSS("height", "32px");
await expect(emojiClear.locator("svg")).toHaveAttribute(
@@ -464,26 +475,24 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
);
await expect(emojiClear.locator("svg")).toHaveCSS("width", "16px");
await expect(emojiClear.locator("svg")).toHaveCSS("height", "16px");
- await expect(emojiClear).toHaveCSS("color", "rgb(0, 0, 0)");
- await expectPhosphor(emojiClear.locator("svg"), "x-circle");
+ await expect(emojiClear).toHaveCSS("color", "rgb(102, 102, 102)");
+ await expectPhosphor(emojiClear.locator("svg"), "x");
await expect(emojiClear.locator("svg path")).toHaveCSS(
"fill",
- "rgb(0, 0, 0)",
+ "rgb(102, 102, 102)",
);
// Mart recreates the clear control when search empties, and on picker remount.
await search.fill("");
await expect(emojiClear).toHaveCount(0);
await search.fill("party");
- await expectPhosphor(emojiClear.locator("svg"), "x-circle");
+ await expectPhosphor(emojiClear.locator("svg"), "x");
await picker.click();
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
await picker.click();
await search.fill("party");
- await expectPhosphor(emojiClear.locator("svg"), "x-circle");
+ await expectPhosphor(emojiClear.locator("svg"), "x");
await expect(
- page.locator(
- '[aria-label="Emoji picker"] svg[class*="sharedSearchIcon"]:visible',
- ),
+ page.locator("em-emoji-picker .search .loupe svg:visible"),
).toHaveCount(1);
const insert = page.getByRole("button", {
name: ":party:",
@@ -502,9 +511,9 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
document.documentElement.dataset.colorMode = mode;
}, mode);
await expect(surface).toHaveAttribute("data-theme", mode);
- await expect(search).toHaveCSS(
+ await expect(searchField).toHaveCSS(
"background-color",
- mode === "dark" ? "rgb(16, 16, 16)" : "rgb(240, 240, 240)",
+ mode === "dark" ? "rgb(26, 26, 26)" : "rgb(255, 255, 255)",
);
await expect(search).toHaveCSS("box-shadow", "none");
await expect(search).toHaveCSS("font-family", /Inter Variable/);
@@ -534,7 +543,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
expect(narrowRegion.y + 1).toBe(narrowSurface.y);
const pane = await page.locator("main").boundingBox();
const popover = await page
- .getByRole("region", { name: "Emoji picker" })
+ .getByRole("dialog", { name: "Emoji picker" })
.boundingBox();
expect(popover.x).toBeGreaterThanOrEqual(pane.x);
expect(popover.x + popover.width).toBeLessThanOrEqual(pane.x + pane.width);
@@ -608,14 +617,11 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
});
expect(emojiGridGutters.left).toBeCloseTo(emojiGridGutters.right, 1);
expect(emojiGridGutters.left).toBeCloseTo(12, 1);
- const scrollbar = page.locator("em-emoji-picker .buzz-scrollbar-track");
- const scrollbarThumb = scrollbar.locator(".buzz-scrollbar-thumb");
- await expect(scrollbar).toBeVisible();
- await expect(scrollbar).toHaveCSS("right", "4px");
- await expect(scrollbar).toHaveCSS("opacity", "0.6");
- await expect(scrollbarThumb).toHaveCSS(
- "background-color",
- "rgb(128, 128, 128)",
+ const scroll = page.locator("em-emoji-picker .scroll");
+ await expect(scroll).toHaveCSS("scrollbar-width", "thin");
+ await expect(scroll).toHaveCSS(
+ "scrollbar-color",
+ "rgb(128, 128, 128) rgba(0, 0, 0, 0)",
);
for (const [index, result] of searchRowPositions.entries())
expect(result.x).toBeCloseTo(firstRow[index].x, 1);
@@ -893,6 +899,9 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and
await search.fill("grinning");
await page.getByRole("button", { name: "😀", exact: true }).click();
await expect(draft()).toHaveJSProperty("value", "😀A draft");
+ // The shared popup retains its contents through its exit transition.
+ // Finish that lifecycle before inspecting the separate composer error.
+ await expect(region).toHaveCount(0);
await draft().fill(":party:");
await draft().press("Enter");
await expect(draft()).toHaveJSProperty("value", ":party:");
diff --git a/tests/browser/gifs.spec.mjs b/tests/browser/gifs.spec.mjs
index fa57d44e1..f0faf6cab 100644
--- a/tests/browser/gifs.spec.mjs
+++ b/tests/browser/gifs.spec.mjs
@@ -86,14 +86,28 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
name: "Insert emoji",
exact: true,
});
+ await draft.fill("unfinished draft");
await emojiTrigger.click();
await expect.poll(() => infoRequests).toBeGreaterThan(0);
- const picker = page.getByRole("region", { name: "Emoji picker" });
+ const picker = page.getByRole("dialog", { name: "Emoji picker" });
await expect(emojiTrigger).toHaveAttribute("aria-busy", "true");
await expect(picker).toBeVisible();
await expect(page.getByRole("tab", { name: "GIF", exact: true })).toHaveCount(
0,
);
+ const initialEmojiSearch = page.getByRole("searchbox", {
+ name: "Search emoji",
+ exact: true,
+ });
+ await expect(initialEmojiSearch).toBeFocused();
+ await initialEmojiSearch.fill("face");
+ const initialSearchNode = await initialEmojiSearch.elementHandle();
+ const initialScroll = picker.locator("em-emoji-picker .scroll");
+ await expect(initialScroll.locator(".category button").first()).toBeVisible();
+ const beforeScroll = await initialScroll.evaluate((node) => {
+ node.scrollTop = 48;
+ return node.scrollTop;
+ });
releaseInfo();
await expect(emojiTrigger).not.toHaveAttribute("aria-busy", "true");
await expect(
@@ -109,8 +123,36 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
name: "Search emoji",
exact: true,
});
- const emojiSearchPosition = await emojiSearch.boundingBox();
+ await emojiSearch.press("ArrowLeft");
+ await expect(emojiSearch).toBeFocused();
+ await expect(emojiSearch.locator("..")).toHaveCSS("outline-style", "none");
+ const emojiSearchFrame = await emojiSearch.locator("..").boundingBox();
+ const emojiTabsBox = await picker.getByRole("tablist").boundingBox();
+ expect(emojiSearchFrame.x).toBeCloseTo(emojiTabsBox.x, 1);
+ expect(emojiSearchFrame.y - emojiTabsBox.y - emojiTabsBox.height).toBeCloseTo(
+ 16,
+ 1,
+ );
+ const emojiResultsBox = await picker
+ .locator("em-emoji-picker .scroll")
+ .boundingBox();
+ const emojiPickerBox = await picker.boundingBox();
+ const emojiFooterBox = await picker
+ .locator("em-emoji-picker #nav")
+ .boundingBox();
+ expect(
+ emojiResultsBox.y - emojiSearchFrame.y - emojiSearchFrame.height,
+ ).toBeCloseTo(12, 1);
+ expect(emojiFooterBox.height).toBeCloseTo(40, 1);
await expect(emojiSearch).toBeFocused();
+ // Discovering GIF support must not replace the focused vendor search.
+ expect(await initialSearchNode.evaluate((node) => node.isConnected)).toBe(
+ true,
+ );
+ await expect(emojiSearch).toHaveValue("face");
+ expect(await initialScroll.evaluate((node) => node.scrollTop)).toBe(
+ beforeScroll,
+ );
await expect(emojiTab).toHaveAttribute("aria-selected", "true");
await expect(emojiTab).toHaveAttribute("aria-controls", /.+/);
await expect(
@@ -135,11 +177,14 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
const search = page.getByRole("searchbox", { name: "Search GIFs" });
await expect(search).toBeFocused();
await expect(search).toHaveValue("hello");
- await draft.fill("unfinished draft");
// Observe the real key's browser-default boundary, not a guessed network delay.
- await search.evaluate((input) => {
+ await draft.evaluate((input) => {
window.gifEnter = { prevented: false, submits: 0 };
- input.form.addEventListener("submit", () => window.gifEnter.submits++);
+ input
+ .closest("form")
+ .addEventListener("submit", () => window.gifEnter.submits++);
+ });
+ await search.evaluate((input) => {
window.addEventListener("keydown", (event) => {
if (event.target === input && event.key === "Enter")
window.gifEnter.prevented = event.defaultPrevented;
@@ -177,8 +222,11 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
await expect(search).toHaveAttribute("autocorrect", "off");
await expect(search).toHaveAttribute("autocapitalize", "off");
const searchFrame = search.locator("..");
- await expect(searchFrame).toHaveClass(/buzz-input-group/);
- await expect(searchFrame).toHaveCSS("background-color", "rgb(240, 240, 240)");
+ await search.press("ArrowLeft");
+ await expect(search).toBeFocused();
+ await expect(searchFrame).toHaveCSS("outline-style", "none");
+ await expect(searchFrame).toHaveClass(/search-field/);
+ await expect(searchFrame).toHaveCSS("background-color", "rgb(255, 255, 255)");
await expect(searchFrame).toHaveCSS("border-top-color", "rgb(128, 128, 128)");
await expect(search).toHaveCSS("color", "rgb(0, 0, 0)");
await expect(search).toHaveCSS("font-family", /Inter Variable/);
@@ -207,9 +255,16 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
});
expect(gifResultGutters.left).toBeCloseTo(gifResultGutters.right, 1);
expect(gifResultGutters.left).toBeCloseTo(12, 1);
- const gifScrollbar = page.getByTestId("gif-scrollbar-track");
- await expect(gifScrollbar).toHaveCSS("right", "4px");
- await expect(gifScrollbar).toHaveCSS("opacity", "0.6");
+ const gifResults = page.getByTestId("klipy-gif-grid").locator("..");
+ const gifResultsBox = await gifResults.boundingBox();
+ expect(gifResultsBox.y).toBeCloseTo(emojiResultsBox.y, 1);
+ expect(gifResultsBox.height).toBeCloseTo(emojiResultsBox.height, 1);
+ expect(await picker.boundingBox()).toEqual(emojiPickerBox);
+ await expect(gifResults).toHaveCSS("scrollbar-width", "thin");
+ await expect(gifResults).toHaveCSS(
+ "scrollbar-color",
+ "rgb(128, 128, 128) rgba(0, 0, 0, 0)",
+ );
expect(gifGridBox.y).toBeGreaterThanOrEqual(
gifSearchPosition.y + gifSearchPosition.height,
);
@@ -229,11 +284,13 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
});
await expect(returningEmojiSearch).toHaveValue("celebrate");
await expect(returningEmojiSearch).toHaveCSS("animation-name", "none");
- const returningEmojiSearchPosition = await returningEmojiSearch.boundingBox();
- expect(returningEmojiSearchPosition.x).toBeCloseTo(emojiSearchPosition.x, 1);
- expect(returningEmojiSearchPosition.y).toBeCloseTo(emojiSearchPosition.y, 1);
+ const returningEmojiSearchPosition = await returningEmojiSearch
+ .locator("..")
+ .boundingBox();
+ expect(returningEmojiSearchPosition.x).toBeCloseTo(emojiSearchFrame.x, 1);
+ expect(returningEmojiSearchPosition.y).toBeCloseTo(emojiSearchFrame.y, 1);
expect(returningEmojiSearchPosition.width).toBeCloseTo(
- emojiSearchPosition.width,
+ emojiSearchFrame.width,
1,
);
await gifTab.click();
@@ -261,7 +318,9 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({
});
await expect(clear).toHaveCSS("width", "32px");
await expect(clear).toHaveCSS("height", "32px");
- await clear.click();
+ await clear.focus();
+ await expect(clear).toHaveCSS("outline-style", "none");
+ await clear.press("Enter");
await expect(search).toHaveValue("");
await expect(search).toBeFocused();
// Clearing starts a debounced request. Old results have the same count, so
diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs
index fbbacbacf..d97443fc8 100644
--- a/tests/browser/mentions.spec.mjs
+++ b/tests/browser/mentions.spec.mjs
@@ -34,13 +34,35 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta
await page
.getByRole("button", { name: "Mention a member", exact: true })
.click();
- const picker = page.getByRole("region", {
+ const picker = page.getByRole("dialog", {
name: "Mention a member or agent",
});
await expect(
picker.getByRole("button", { name: new RegExp(key) }),
).toBeVisible();
- await picker.getByRole("button", { name: new RegExp(key) }).click();
+ const search = picker.getByRole("searchbox");
+ await expect(search).toBeFocused();
+ await expect(picker).toHaveCSS("width", "380px");
+ expect((await picker.boundingBox()).height).toBeLessThanOrEqual(360);
+ await search.fill("");
+ const choice = picker.getByRole("button", {
+ name: new RegExp(key),
+ });
+ const index = await choice.evaluate((node) =>
+ [
+ ...node.parentElement.querySelectorAll("[data-mention-choice]"),
+ ].indexOf(node),
+ );
+ await search.press("ArrowDown");
+ for (let step = 0; step < index; step++)
+ await page.keyboard.press("ArrowDown");
+ await expect(choice).toBeFocused();
+ await expect(choice).toHaveCSS("padding", "8px");
+ await expect(choice.locator(".buzz-avatar")).toHaveCSS("width", "40px");
+ await choice.press("ArrowUp");
+ await page.keyboard.press("ArrowDown");
+ await expect(choice).toBeFocused();
+ await choice.press("Enter");
};
const order = () =>
page
@@ -364,7 +386,7 @@ test("selected mentions inside code remain visible through draft restore and cha
.getByRole("button", { name: "Mention a member", exact: true })
.click();
await page
- .getByRole("region", { name: "Mention a member or agent" })
+ .getByRole("dialog", { name: "Mention a member or agent" })
.getByRole("button", { name: new RegExp(key) })
.click();
}
@@ -452,7 +474,7 @@ test("namesake recipient qualifiers remain visible on touch after live name chan
.getByRole("button", { name: "Mention a member", exact: true })
.tap();
await page
- .getByRole("region", { name: "Mention a member or agent" })
+ .getByRole("dialog", { name: "Mention a member or agent" })
.getByRole("button", { name: new RegExp(key) })
.tap();
};
diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs
index 1893bc9f7..1f1cb17a3 100644
--- a/tests/browser/new-message.spec.mjs
+++ b/tests/browser/new-message.spec.mjs
@@ -486,7 +486,7 @@ test("empty compose, keyboard selection, pagination, removal effects, retry, the
await page
.getByRole("button", { name: "Mention a member", exact: true })
.click();
- const mentions = page.getByRole("region", {
+ const mentions = page.getByRole("dialog", {
name: "Mention a member or agent",
});
const averyMention = mentions.getByRole("button", { name: /^Avery Chen / });
diff --git a/tests/browser/product-ui.spec.mjs b/tests/browser/product-ui.spec.mjs
index 3edc14a93..40b74942f 100644
--- a/tests/browser/product-ui.spec.mjs
+++ b/tests/browser/product-ui.spec.mjs
@@ -118,7 +118,7 @@ test("playground distinguishes same-name people and agents in the real composer"
.getByRole("button", { name: "Mention a member", exact: true })
.click();
await page
- .getByRole("region", { name: "Mention a member or agent" })
+ .getByRole("dialog", { name: "Mention a member or agent" })
.getByRole("button", { name: `${label} ${key}`, exact: true })
.click();
labels.push(`@${label}`);
diff --git a/tests/browser/reactions.spec.mjs b/tests/browser/reactions.spec.mjs
index abc46f50f..13806817f 100644
--- a/tests/browser/reactions.spec.mjs
+++ b/tests/browser/reactions.spec.mjs
@@ -61,7 +61,7 @@ test("reaction plus opens a visible emoji-only picker, restores focus and publis
root.getByRole("group", { name: "Message actions" }),
).toHaveCSS("opacity", "1");
const padding = await search.evaluate((input) => {
- const field = input.getBoundingClientRect();
+ const field = input.parentElement.getBoundingClientRect();
const picker = input
.getRootNode()
.querySelector("#root")
@@ -74,7 +74,7 @@ test("reaction plus opens a visible emoji-only picker, restores focus and publis
});
expect(padding.top).toBeCloseTo(padding.left, 1);
expect(padding.top).toBeCloseTo(padding.right, 1);
- await expect(search).toHaveCSS("border-radius", "12px");
+ await expect(search.locator("..")).toHaveCSS("border-radius", "159984px");
await expect(
page.getByRole("tab", { name: "GIF", exact: true }),
).toHaveCount(0);
@@ -288,6 +288,7 @@ sourceTest(
await search.press("Escape");
await expect(search).toHaveCount(0);
await expect(second).toBeFocused();
+ await expect(second).toHaveAttribute("aria-expanded", "false");
await first.focus();
await first.press("Enter");
await expect(search).toBeVisible();
@@ -298,6 +299,7 @@ sourceTest(
await search.press("Escape");
await expect(search).toHaveCount(0);
await expect(second).toBeFocused();
+ await expect(second).toHaveAttribute("aria-expanded", "false");
await second.press("Enter");
await expect(search).toBeVisible();
await page.evaluate(() => window.emojiFixture.remount());
diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs
index 2516d723c..af22649a9 100644
--- a/tests/browser/thread-unread.spec.mjs
+++ b/tests/browser/thread-unread.spec.mjs
@@ -117,6 +117,10 @@ test("thread buttons show observed unread independently, clear only after readin
await expect(
actions.getByRole("menuitem", { name: "New session" }),
).toBeVisible();
+ // Leave the hover trigger while the context menu still owns focus. Otherwise
+ // its delayed hover close can overlap the later keyboard-open assertion.
+ await page.mouse.move(0, 0);
+ await expect(popover).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(actions).toHaveCount(0);
await expect(alpha).toBeFocused();
@@ -125,11 +129,8 @@ test("thread buttons show observed unread independently, clear only after readin
await page.keyboard.press("Escape");
await expect(actions).toHaveCount(0);
await expect(alpha).toBeFocused();
- // Context-menu dismissal can leave Activity hover-open under the pointer.
- // Leave hover and finish its exit before Enter tests keyboard opening rather
- // than toggling it closed and observing the still-visible exit animation.
- await page.mouse.move(0, 0);
await expect(popover).toHaveCount(0);
+ await expect(alpha).toHaveAttribute("aria-expanded", "false");
await alpha.press("Enter");
await expect(alpha).toHaveAttribute("aria-expanded", "true");
await expect(popover).toBeVisible();
diff --git a/tests/browser/typeahead.spec.mjs b/tests/browser/typeahead.spec.mjs
index 9c27c8afc..dd61254f4 100644
--- a/tests/browser/typeahead.spec.mjs
+++ b/tests/browser/typeahead.spec.mjs
@@ -34,7 +34,7 @@ test("mention completion distinguishes exact agent identity without reshaping a
await page
.getByRole("button", { name: "Mention a member", exact: true })
.click();
- const picker = page.getByRole("region", {
+ const picker = page.getByRole("dialog", {
name: "Mention a member or agent",
});
await expectAvatarShape(
diff --git a/tests/fixtures/conversation-consumer/plugin.ts b/tests/fixtures/conversation-consumer/plugin.ts
index e1a82f7a8..2cdcfff3e 100644
--- a/tests/fixtures/conversation-consumer/plugin.ts
+++ b/tests/fixtures/conversation-consumer/plugin.ts
@@ -64,6 +64,12 @@ export function apply(ctx: Context) {
channelName: string;
}) {
const [renders, rerender] = React.useState(0);
+ React.useEffect(() => {
+ const render = () => rerender((value) => value + 1);
+ globalThis.addEventListener("conversation-fixture-rerender", render);
+ return () =>
+ globalThis.removeEventListener("conversation-fixture-rerender", render);
+ }, []);
const session = connection.session;
const subscribe = React.useCallback(
(listener: () => void) =>
diff --git a/tests/fixtures/design-system/ui/FloatingSurfacesPage.tsx b/tests/fixtures/design-system/ui/FloatingSurfacesPage.tsx
index 730f135a7..607ba2be3 100644
--- a/tests/fixtures/design-system/ui/FloatingSurfacesPage.tsx
+++ b/tests/fixtures/design-system/ui/FloatingSurfacesPage.tsx
@@ -39,6 +39,14 @@ export function FloatingSurfacesPage() {
+
+ Small action menus opt into 10px outer corners, 8px rows, and a 4px
+ inset. Other floating surfaces retain the 24px panel radius. See the{" "}
+
+ Popover examples
+ {" "}
+ for the content-based sizing rule and both treatments.
+
+ {title}
+ {description}
+
+ {children}
+
+
+ );
+}
+
+function Related() {
+ return (
+
+
+ Menu
+ {" "}
+ for actions and choices.{" "}
+
+ Popover
+ {" "}
+ for supporting content and short forms.{" "}
+
+ ChoiceRow
+ {" "}
+ for row content. Use Select or Combobox for a form value.
+
+ );
+}
+
+function ActionMenu() {
+ const [sort, setSort] = useState("recent");
+ const [notifications, setNotifications] = useState(true);
+ return (
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+ Mark all as read
+
+
+
+
+
+ Notifications
+
+
+
+ Sort
+
+
+ Recent
+ A–Z
+
+
+
+ Disabled action⌘D
+
+
+
+
+
+ );
+}
+
+function AgentChoices() {
+ const [agent, setAgent] = useState("studio");
+ return (
+
+ Choose an agent} />
+
+
+ Agents
+
+
+ }
+ label="No agent selected"
+ />
+
+
+
+ }
+ label="Studio assistant"
+ description="Already in this session"
+ />
+
+
+
+ }
+ label="Research and accessibility assistant"
+ description="Adds to session and channel"
+ />
+
+
+
+ }
+ label="Archive assistant"
+ description="Unavailable on this connection"
+ />
+
+
+
+
+
+ Adding an agent gives it access to this session’s history.
+
+
+
+ );
+}
+
+export function MenuSpecimen() {
+ const [lastAction, setLastAction] = useState("No action selected");
+ const [dialog, setDialog] = useState(false);
+ const [workspace, setWorkspace] = useState("1");
+ return (
+
+
+
+
+ Open menu} />
+
+
+ Workspace
+ setLastAction("Copied workspace link")}>
+
+
+
+ About popovers
+
+ Transfer ownership
+
+
+ setLastAction("Archive selected (preview only)")}
+ >
+ Archive workspace
+
+
+
+
+
+ {lastAction}
+
+
+
+
+
+
+
+
+
+ Choose workspace} />
+
+
+ {Array.from({ length: 24 }, (_, i) => String(i + 1)).map(
+ (workspaceId) => (
+
+ }
+ label={`Workspace ${workspaceId}`}
+ />
+
+ ),
+ )}
+
+
+
+
+
+
+ Context actions} />
+
+ setLastAction("Copied context link")}>
+ Copy link
+
+ Unavailable action
+
+
+
+
+ setDialog(true)}>Open menu dialog
+
+
+
+
+
+ Implementation: MenuRoot → MenuTrigger + MenuPopup. Put group labels
+ inside MenuGroup. Use MenuRadioItem for one choice, MenuCheckboxItem for
+ independent choices, and tone="danger" for destructive actions. Keep
+ forms in a Popover instead of a Menu.
+
+
+ );
+}
+
+export function PopoverSpecimen() {
+ const [action, setAction] = useState("");
+ const [name, setName] = useState("Design studio");
+ const [draft, setDraft] = useState(name);
+ const [open, setOpen] = useState(false);
+ const [dialog, setDialog] = useState(false);
+ return (
+
+
+
+ Choose the surface by its content
+
+ Small action menus use 10px outer corners, 8px row corners, and a 4px
+ inset. Their quieter hover fill uses Buzz’s subtle-hover color, with
+ immediate pointer and keyboard feedback. The account menu and a short
+ channel action list use this treatment.
+
+
+ Content popovers, larger action or choice menus, forms, activity
+ previews, and emoji/mention pickers keep 24px panel corners and their
+ existing spacing. A narrow viewport does not turn a content popover
+ into a small action menu. Both treatments share the same surface,
+ border, shadow, and opening motion.
+
+
+ Opt in with size="compact" on MenuPopup. For an account-style
+ PopoverPopup, use size="compact" with padding="list". Default and wide
+ surfaces retain the larger corners. Use Menu for arrow-key action
+ lists; Popover retains normal Tab navigation for interactive content.
+
+
+
+
+
+ Account actions} />
+
+
+
+ Alex Morgan
+
+ }
+ onClick={() => setAction("Settings selected.")}
+ />
+ }
+ />
+
+
+
+
+ Channel actions} />
+
+ setAction("New session selected.")}>
+ New session
+
+
+
+
+
+ {action && (
+
+ {action}
+
+ )}
+
+
+ Open popover} />
+
+ Workspace access
+
+ Only people invited to this workspace can read its conversations.
+
+
+
+
+
+ {
+ setOpen(next);
+ if (next) setDraft(name);
+ }}
+ >
+ Edit workspace name} />
+
+ Edit workspace
+
+ Choose a name your team will recognize.
+
+
+
+
+
+
+ Workspace: {name}
+
+
+
+ Recent activity}
+ />
+
+
+ }
+ icon={
+
+ }
+ />
+ }
+ />
+
+ }
+ icon={
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+ {(["top", "right", "bottom", "left"] as const).map((side) => (
+
+ {side}} />
+
+ Anchored {side}
+
+ The popup stays within the available viewport.
+
+
+
+ ))}
+
+
+
+ setDialog(true)}>Open popover dialog
+
+
+ View access details} />
+
+ Invite only
+
+ Workspace owners manage invitations.
+
+
+
+
+
+
+
+ Implementation: PopoverRoot → PopoverTrigger + PopoverPopup. Label the
+ popup with PopoverTitle or aria-label. Use PopoverDescription for
+ supporting copy and PopoverClose for a close action. Default content
+ padding is 16px; padding="list" gives rows their own spacing, and
+ padding="none" lets embedded pickers own their internal spacing. Use
+ size="wide" for richer content. Feature code owns saving, loading, and
+ errors.
+
+
+ );
+}
+
+export function ChoiceRowSpecimen() {
+ return (
+
+
+
+
+
+
+
+
+ }
+ label="Alex Morgan"
+ description="Workspace owner"
+ />
+
+ }
+ label="Studio assistant"
+ description="Adds to session and channel"
+ />
+
+
+
+
+
+
+ ChoiceRow accepts label, description, leading, and trailing content.
+ Keep slots non-interactive so the containing menu item remains one
+ action. It does not add its own padding, hover state, tab stop, or click
+ handler.
+
+
+ );
+}
diff --git a/tests/fixtures/design-system/ui/componentSpecimens.tsx b/tests/fixtures/design-system/ui/componentSpecimens.tsx
index 1d97dab21..18dfbaaff 100644
--- a/tests/fixtures/design-system/ui/componentSpecimens.tsx
+++ b/tests/fixtures/design-system/ui/componentSpecimens.tsx
@@ -1,3 +1,8 @@
+import {
+ MenuSpecimen,
+ PopoverSpecimen,
+ ChoiceRowSpecimen,
+} from "./MenuSpecimens";
import {
InputExamples,
TextareaExamples,
@@ -7,7 +12,6 @@ import {
} from "./FormSpecimens";
import { ToastSpecimens } from "./ToastSpecimens";
import { ButtonSpecimen, IconButtonSpecimen } from "./ButtonSpecimens";
-import { Dialog } from "../../../../src/shared/design-system/ui/Dialog";
import { AlertDialog } from "../../../../src/shared/design-system/ui/AlertDialog";
import { DialogSpecimens } from "./DialogSpecimens";
import { Tooltip } from "../../../../src/shared/design-system/ui/Tooltip";
@@ -24,8 +28,6 @@ import { PanelSwapPlaygrounds } from "./PanelSwapPlaygrounds";
import { FlexWorkspace } from "../../../../src/shared/design-system/ui/FlexWorkspace";
import { BentoSpecimen } from "./BentoSpecimen";
import {
- BellIcon,
- CheckIcon,
DotsThreeIcon,
HashIcon,
ChatCircleIcon,
@@ -35,23 +37,7 @@ import type { ReactNode } from "react";
import { useState } from "react";
import { Switch } from "../../../../src/shared/design-system/ui/Switch";
import { Accordion } from "../../../../src/shared/design-system/ui/Accordion";
-import {
- ContextMenuRoot,
- ContextMenuTrigger,
- MenuCheckboxItem,
- MenuIcon,
- MenuItem,
- MenuPopup,
- MenuRadioGroup,
- MenuRadioItem,
- MenuRoot,
- MenuSeparator,
- MenuSubmenu,
- MenuSubmenuPopup,
- MenuSubmenuTrigger,
- MenuTrailing,
- MenuTrigger,
-} from "../../../../src/shared/design-system/ui/Menu";
+
import { Avatar } from "../../../../src/shared/design-system/ui/Avatar";
import { InlineChip } from "../../../../src/shared/design-system/ui/InlineChip";
import { Button } from "../../../../src/shared/design-system/ui/Button";
@@ -732,75 +718,6 @@ function SwitchSpecimen() {
);
}
-function ActionMenuSpecimen() {
- const [sort, setSort] = useState("recent");
- const [notifications, setNotifications] = useState(true);
- return (
-
- }
- />
- }
- />
-
-
-
-
-
- Mark all as read
-
-
-
-
-
- Notifications
-
-
-
- Sort
-
-
- Recent
- A–Z
-
-
-
- Disabled action
- ⌘D
-
-
-
-
-
- );
-}
-
-function MenuSpecimen() {
- const [open, setOpen] = useState(false);
- return (
-
-
-
- Context actions} />
-
- Copy link
- Unavailable action
-
-
-
setOpen(true)}>Open menu dialog
-
-
-
-
- );
-}
-
function AlertDialogSpecimen() {
const [open, setOpen] = useState(false);
return (
@@ -946,6 +863,8 @@ export const COMPONENT_SPECIMENS: Record ReactNode> = {
panel: PanelSpecimen,
tabs: TabsSpecimen,
menu: MenuSpecimen,
+ popover: PopoverSpecimen,
+ "choice-row": ChoiceRowSpecimen,
select: SelectExamples,
combobox: ComboboxExamples,
switch: SwitchSpecimen,
diff --git a/tests/fixtures/design-system/viewer.spec.ts b/tests/fixtures/design-system/viewer.spec.ts
index 1d029ec7d..75a16de42 100644
--- a/tests/fixtures/design-system/viewer.spec.ts
+++ b/tests/fixtures/design-system/viewer.spec.ts
@@ -1569,3 +1569,273 @@ test("form choice popups settle, retain exit presence, and respect immediate mot
await finish();
}
});
+
+// Native portal focus and dismissal cannot be established by a DOM emulator.
+test("popovers return focus and nested popovers close before their dialog", async ({
+ page,
+}) => {
+ await page.goto(`${viewer}#/design/components/popover`);
+ const trigger = page.getByRole("button", {
+ name: "Edit workspace name",
+ exact: true,
+ });
+ await expect(trigger).toBeVisible();
+ await page.keyboard.press("Tab");
+ await trigger.focus();
+ await page.keyboard.press("Enter");
+ const popup = page.getByRole("dialog", {
+ name: "Edit workspace",
+ exact: true,
+ });
+ const input = popup.getByRole("textbox", { name: "Workspace name" });
+ await expect(input).toBeFocused();
+ await expect(popup).toHaveCSS("transition-duration", "0s");
+ await input.fill("Research studio");
+ await page.keyboard.press("Escape");
+ await expect(popup).toHaveCount(0);
+ await expect(trigger).toBeFocused();
+ await expect(page.getByRole("status")).toHaveText("Workspace: Design studio");
+ await trigger.click();
+ await expect(input).toHaveValue("Design studio");
+ await input.fill("Research studio");
+ await popup.getByRole("button", { name: "Save", exact: true }).click();
+ await expect(page.getByRole("status")).toHaveText(
+ "Workspace: Research studio",
+ );
+ const activityTrigger = page.getByRole("button", {
+ name: "Recent activity",
+ exact: true,
+ });
+ await activityTrigger.hover();
+ const activity = page.getByRole("dialog", {
+ name: "Recent activity",
+ exact: true,
+ });
+ await expect(activity).toBeVisible();
+ await activity.hover();
+ await expect(activity).toBeVisible();
+ await activity.getByRole("button", { name: /Alex Morgan/ }).click();
+ await expect(activity).toHaveCount(0);
+ await page.getByRole("button", { name: "Open popover dialog" }).click();
+ const dialog = page.getByRole("dialog", {
+ name: "Workspace details",
+ exact: true,
+ });
+ const nestedTrigger = dialog.getByRole("button", {
+ name: "View access details",
+ });
+ await nestedTrigger.click();
+ const nested = page.getByRole("dialog", { name: "Invite only", exact: true });
+ await expect(nested.getByRole("button", { name: "Done" })).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(nested).toHaveCount(0);
+ await expect(dialog).toBeVisible();
+ await expect(nestedTrigger).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(dialog).toHaveCount(0);
+});
+
+// Popup collision geometry, scrolling and text scaling need a layout engine.
+test("popover placements and rich choices fit narrow screens and enlarged text", async ({
+ page,
+}) => {
+ for (const mode of ["light", "dark"]) {
+ await page.goto(`${viewer}#/design/components/popover`);
+ const toggle = page.getByRole("button", { name: `Use ${mode} mode` });
+ if (await toggle.count()) await toggle.click();
+ await page.setViewportSize({ width: 390, height: 850 });
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ for (const side of ["top", "right", "bottom", "left"]) {
+ await page.getByRole("button", { name: side, exact: true }).click();
+ const popup = page.getByRole("dialog", {
+ name: `Anchored ${side}`,
+ exact: true,
+ });
+ await expect(popup).toBeVisible();
+ await expect(popup).toHaveCSS("transform", "none");
+ const box = await popup.boundingBox();
+ if (!box) throw new Error("Popover has no visible bounds");
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(390);
+ expect(box.y).toBeGreaterThanOrEqual(0);
+ expect(box.y + box.height).toBeLessThanOrEqual(850);
+ await expect
+ .poll(() => popup.evaluate((e) => e.scrollWidth <= e.clientWidth))
+ .toBe(true);
+ await page.keyboard.press("Escape");
+ await expect(popup).toHaveCount(0);
+ }
+ await page.goto(`${viewer}#/design/components/menu`);
+ await page.evaluate(() => {
+ document.documentElement.style.fontSize = "200%";
+ });
+ await page
+ .getByRole("button", { name: "Choose an agent", exact: true })
+ .click();
+ const menu = page.getByRole("menu", {
+ name: "Choose an agent",
+ exact: true,
+ });
+ await expect(menu).toHaveCSS("transform", "none");
+ await expect
+ .poll(() => menu.evaluate((e) => e.scrollWidth <= e.clientWidth))
+ .toBe(true);
+ const disabled = page.getByRole("menuitemradio", {
+ name: "Archive assistant Unavailable on this connection",
+ });
+ await expect(disabled.locator(".buzz-choice-row-label")).toHaveCSS(
+ "color",
+ await disabled.evaluate((e) => getComputedStyle(e).color),
+ );
+ await page.keyboard.press("Escape");
+ await page
+ .getByRole("button", { name: "Choose workspace", exact: true })
+ .click();
+ const list = page.getByRole("menu", {
+ name: "Choose workspace",
+ exact: true,
+ });
+ await expect(list).toHaveCSS("transform", "none");
+ await expect
+ .poll(() => list.evaluate((e) => e.scrollHeight > e.clientHeight))
+ .toBe(true);
+ await page.keyboard.press("End");
+ const last = page.getByRole("menuitemradio", {
+ name: "Workspace 24",
+ exact: true,
+ });
+ await expect(last).toBeFocused();
+ const listBox = await list.boundingBox();
+ const rowBox = await last.boundingBox();
+ if (!listBox || !rowBox)
+ throw new Error("Menu or last row has no visible bounds");
+ expect(rowBox.y).toBeGreaterThanOrEqual(listBox.y);
+ expect(rowBox.y + rowBox.height).toBeLessThanOrEqual(
+ listBox.y + listBox.height,
+ );
+ await page.keyboard.press("Enter");
+ await expect(list).toHaveCount(0);
+ }
+});
+
+// Reduced-motion preference must suppress popup animation in the browser.
+test("popover reduced motion and outside dismissal remain immediate", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto(`${viewer}#/design/components/popover`);
+ await page.getByRole("button", { name: "Open popover", exact: true }).click();
+ const popup = page.getByRole("dialog", {
+ name: "Workspace access",
+ exact: true,
+ });
+ await expect(popup).toBeVisible();
+ await expect(popup).toHaveCSS("transition-duration", "0s");
+ await page.getByRole("heading", { name: "Popover", exact: true }).click();
+ await expect(popup).toHaveCount(0);
+});
+
+// Hold real CSS transitions to inspect entry/exit frames and Base UI presence.
+test("menus and popovers reuse the dropdown unblur and placement offset", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ document.addEventListener(
+ "transitionrun",
+ (event) => {
+ if (
+ !(event.target instanceof HTMLElement) ||
+ !event.target.matches(".buzz-menu-popup, .buzz-popover-popup")
+ )
+ return;
+ for (const animation of event.target.getAnimations()) animation.pause();
+ },
+ true,
+ );
+ });
+ const finish = () =>
+ page.evaluate(() => {
+ for (const animation of document.getAnimations()) animation.finish();
+ });
+ for (const example of [
+ { page: "menu", trigger: "Open menu", selector: ".buzz-menu-popup" },
+ { page: "popover", trigger: "top", selector: ".buzz-popover-popup" },
+ { page: "popover", trigger: "right", selector: ".buzz-popover-popup" },
+ ]) {
+ await page.goto(`${viewer}#/design/components/${example.page}`);
+ await page.emulateMedia({ reducedMotion: "no-preference" });
+ const trigger = page.getByRole("button", {
+ name: example.trigger,
+ exact: true,
+ });
+ const popup = page.locator(example.selector);
+ const paused = () =>
+ expect
+ .poll(() =>
+ popup.evaluate((el) =>
+ el
+ .getAnimations()
+ .some((animation) => animation.playState === "paused"),
+ ),
+ )
+ .toBe(true);
+ try {
+ await trigger.click();
+ await paused();
+ await expect(popup).toHaveCSS(
+ "transition-duration",
+ "0.075s, 0.075s, 0.075s",
+ );
+ const side = await popup.getAttribute("data-side");
+ const offset =
+ side === "top"
+ ? "translateY(2px)"
+ : side === "right"
+ ? "translateX(-2px)"
+ : side === "left"
+ ? "translateX(2px)"
+ : "translateY(-2px)";
+ const frames = await popup.evaluate((el) =>
+ el
+ .getAnimations()
+ .flatMap((animation) =>
+ (animation.effect as KeyframeEffect).getKeyframes(),
+ ),
+ );
+ expect(frames.some((frame) => frame.filter === "blur(2px)")).toBe(true);
+ expect(frames.some((frame) => frame.transform === offset)).toBe(true);
+ await finish();
+ await expect(popup).toHaveCSS("filter", "blur(0px)");
+ await expect(popup).toHaveCSS("transform", "none");
+ await trigger.click();
+ await expect(popup).toHaveAttribute("data-ending-style", "");
+ await paused();
+ await expect(popup).toHaveCSS("transition-duration", "0.06s");
+ await finish();
+ await expect(popup).toHaveCount(0);
+
+ await page.keyboard.press("Tab");
+ await trigger.focus();
+ await trigger.press("Enter");
+ await expect(popup).toBeVisible();
+ await expect(popup).toHaveCSS("transition-duration", "0s");
+ await expect(popup).toHaveCSS("filter", "none");
+ await expect(popup).toHaveCSS("transform", "none");
+ await page.keyboard.press("Escape");
+ await expect(popup).toHaveCount(0);
+
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await trigger.click();
+ await expect(popup).toBeVisible();
+ await expect(popup).toHaveCSS("transition-duration", "0s");
+ await expect(popup).toHaveCSS("filter", "none");
+ await expect(popup).toHaveCSS("transform", "none");
+ await page.keyboard.press("Escape");
+ await expect(popup).toHaveCount(0);
+ } finally {
+ await finish();
+ }
+ }
+});