From efae4098c58617039d015adfd4ed5853bd8f71c5 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Thu, 17 Sep 2026 08:55:04 -0400 Subject: [PATCH 1/3] Compose Base UI dialogs tooltips and tab panels Signed-off-by: Arjun Mahanti --- src/shared/design-system/DESIGN.md | 17 ++ .../design-system/styles/components.css | 4 +- src/shared/design-system/styles/globals.css | 1 + src/shared/design-system/styles/overlays.css | 99 ++++++++++++ src/shared/design-system/styles/tokens.css | 4 + src/shared/design-system/ui/Dialog.tsx | 83 ++++++++++ src/shared/design-system/ui/Field.tsx | 2 +- .../design-system/ui/NavigationItem.tsx | 15 +- src/shared/design-system/ui/Tabs.tsx | 40 ++++- src/shared/design-system/ui/Tooltip.tsx | 43 +++++ .../design-system/ui/compositions.test.tsx | 147 ++++++++++++++++++ src/shared/design-system/ui/registry.ts | 31 ++++ .../design-system/ui/componentSpecimens.tsx | 40 +++++ 13 files changed, 509 insertions(+), 17 deletions(-) create mode 100644 src/shared/design-system/styles/overlays.css create mode 100644 src/shared/design-system/ui/Dialog.tsx create mode 100644 src/shared/design-system/ui/Tooltip.tsx create mode 100644 src/shared/design-system/ui/compositions.test.tsx diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index f3304a2d..575f140c 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -88,6 +88,23 @@ carry the shared field appearance. RadioGroup is for one choice, Checkbox for an independent choice and Switch for an immediate on/off setting. Use the native form semantics exposed by those Base UI primitives rather than duplicating them. +## Compositions + +Dialog composes a Base UI modal with a shared title, optional description, body, +close button and actions. Pending operations set preventClose so Escape and the +close button agree. It retains the app's explicit dismissal behavior: outside +clicks do not discard a form. Provide initialFocus for search dialogs and +finalFocus when a flow has an external trigger or opens a second dialog. + +Use Tooltip for short hints on labelled controls; use PreviewCard for richer +content. Tooltip owns its description link and inherits placement, focus and +Escape behavior from Base UI. Overlay layers keep menus and hints above dialogs. + +Tabs with content use renderPanel, which lets Base UI connect each tab and panel. +Route navigation uses NavigationItem with aria-current instead. NavigationItem +forwards normal button events, refs and data attributes so unread observation, +preloading and product shortcuts remain with the caller. + ## State - **Design default, hover, pressed, focus, selected, disabled and loading states where they apply.** Pressed changes fill without moving the control. Loading keeps the label footprint and prevents repeated activation; CSS alone cannot enforce it. diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 8db23ca8..01bea7e4 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -200,7 +200,7 @@ /* A preview is supplemental context; destination cards can also open it. It is portal-rendered by Base UI so a clipped parent cannot hide it. */ .buzz-preview-card { - z-index: 1000; + z-index: var(--layer-popover); display: grid; width: max-content; max-width: min(18rem, 75vw); @@ -771,7 +771,7 @@ background: var(--surface-popover); color: var(--text-standard); box-shadow: var(--shadow-sm); - z-index: 1000; + z-index: var(--layer-popover); } .buzz-select-group-label { padding: var(--space-2) var(--space-3) var(--space-1); diff --git a/src/shared/design-system/styles/globals.css b/src/shared/design-system/styles/globals.css index 2974c7fc..a32094e2 100644 --- a/src/shared/design-system/styles/globals.css +++ b/src/shared/design-system/styles/globals.css @@ -23,6 +23,7 @@ @import "./typography-base.css"; @import "./components.css"; @import "./forms.css"; +@import "./overlays.css"; @import "./chips.css"; @import "./bento.css"; @source "../"; diff --git a/src/shared/design-system/styles/overlays.css b/src/shared/design-system/styles/overlays.css new file mode 100644 index 00000000..7f9a7438 --- /dev/null +++ b/src/shared/design-system/styles/overlays.css @@ -0,0 +1,99 @@ +@layer components { + .buzz-dialog-backdrop { + position: fixed; + inset: 0; + z-index: var(--layer-dialog-backdrop); + background: var(--bg-scrim); + } + .buzz-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: var(--layer-dialog); + display: flex; + flex-direction: column; + gap: var(--space-4); + width: min(31rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 3rem); + overflow: auto; + border: 1px solid var(--border-standard); + border-radius: var(--radius-panel); + padding: var(--space-6); + background: var(--surface-popover); + color: var(--text-standard); + box-shadow: var(--shadow-sm); + } + .buzz-dialog-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: var(--space-4); + } + .buzz-dialog-header > h2 { + margin: 0; + } + .buzz-dialog-description { + @apply text-body-sm; + margin: 0; + color: var(--text-subtle); + } + .buzz-dialog-body { + min-width: 0; + } + .buzz-dialog-actions { + display: flex; + flex-wrap: wrap; + justify-content: end; + gap: var(--space-3); + } + .buzz-tooltip-positioner { + z-index: var(--layer-tooltip); + } + .buzz-tooltip { + @apply text-label-sm; + max-width: min(24rem, calc(100vw - 2rem)); + border-radius: var(--radius-pill); + padding: var(--space-2) var(--space-4); + background: var(--affordance-prominent); + color: var(--text-inverse); + box-shadow: var(--shadow-sm); + overflow-wrap: anywhere; + } + .buzz-tab-panels { + display: grid; + gap: var(--space-4); + min-width: 0; + } + .buzz-tabs-panel { + min-width: 0; + } + html[data-keyboard-navigation] .buzz-tabs-panel:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + } + .navigation-item[data-variant="pill"] { + width: auto; + flex-shrink: 0; + border-radius: var(--radius-pill); + padding-inline: var(--space-4); + } + .navigation-item[data-variant="pill"] .navigation-item-label { + flex: initial; + } + .navigation-item[data-variant="pill"][data-selected] { + background: var(--surface-panel); + color: var(--text-standard); + } + .navigation-item:active { + background: var(--affordance-subtle-pressed); + } + @media (max-width: 480px) { + .buzz-dialog { + padding: var(--space-4); + } + .buzz-dialog-actions > .buzz-button { + flex: 1 1 auto; + } + } +} diff --git a/src/shared/design-system/styles/tokens.css b/src/shared/design-system/styles/tokens.css index 984ed333..8bff4b09 100644 --- a/src/shared/design-system/styles/tokens.css +++ b/src/shared/design-system/styles/tokens.css @@ -625,6 +625,10 @@ inset and a 4px gap. */ --space-chip-inset: 0.1875rem; --space-chip-gap: var(--space-1); + --layer-dialog-backdrop: 1000; + --layer-dialog: 1001; + --layer-popover: 1100; + --layer-tooltip: 1200; --size-topbar: 3.5rem; --size-panel-header: 3.5rem; --size-control-sm: 2rem; diff --git a/src/shared/design-system/ui/Dialog.tsx b/src/shared/design-system/ui/Dialog.tsx new file mode 100644 index 00000000..57620cc2 --- /dev/null +++ b/src/shared/design-system/ui/Dialog.tsx @@ -0,0 +1,83 @@ +import { Dialog as BaseDialog } from "@base-ui/react/dialog"; +import { IconX } from "@tabler/icons-react"; +import type { ComponentProps, ReactNode } from "react"; +import { IconButton } from "./IconButton"; + +type PopupProps = ComponentProps; +export type DialogProps = { + open: boolean; + onOpenChange(open: boolean): void; + title: ReactNode; + description?: ReactNode; + children: ReactNode; + actions?: ReactNode; + closeLabel?: string; + /** A pending operation can prevent all user dismissal paths. */ + preventClose?: boolean; + initialFocus?: PopupProps["initialFocus"]; + finalFocus?: PopupProps["finalFocus"]; +}; + +/** Base UI owns the modal, portal, focus trap, Escape and focus restoration. */ +export function Dialog({ + open, + onOpenChange, + title, + description, + children, + actions, + closeLabel = "Close", + preventClose = false, + initialFocus, + finalFocus, +}: DialogProps) { + return ( + { + if (!next && preventClose) { + details.cancel(); + return; + } + onOpenChange(next); + }} + > + + + +
+ + {title} + +
+ {description && ( + + {description} + + )} +
{children}
+ {actions && ( +
{actions}
+ )} +
+
+
+ ); +} diff --git a/src/shared/design-system/ui/Field.tsx b/src/shared/design-system/ui/Field.tsx index e2c41986..aec0d1ba 100644 --- a/src/shared/design-system/ui/Field.tsx +++ b/src/shared/design-system/ui/Field.tsx @@ -34,7 +34,7 @@ export function Field({ )} {error && ( - + {error} )} diff --git a/src/shared/design-system/ui/NavigationItem.tsx b/src/shared/design-system/ui/NavigationItem.tsx index fae53fb6..2469c740 100644 --- a/src/shared/design-system/ui/NavigationItem.tsx +++ b/src/shared/design-system/ui/NavigationItem.tsx @@ -1,5 +1,5 @@ import { Button as BaseButton } from "@base-ui/react/button"; -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; export function NavigationItem({ label, @@ -7,23 +7,26 @@ export function NavigationItem({ trailing, selected = false, inset = false, - onClick, + variant = "row", + ...props }: { label: string; icon?: ReactNode; trailing?: ReactNode; selected?: boolean; inset?: boolean; - onClick?: () => void; -}) { + variant?: "row" | "pill"; +} & Omit, "className" | "children">) { return ( {icon} {label} diff --git a/src/shared/design-system/ui/Tabs.tsx b/src/shared/design-system/ui/Tabs.tsx index ad9e4e60..347719bc 100644 --- a/src/shared/design-system/ui/Tabs.tsx +++ b/src/shared/design-system/ui/Tabs.tsx @@ -31,7 +31,10 @@ export function Tabs({ variant = "chrome", previewValue, arrivalValue, + renderPanel, }: { + /** Omit only when composing an existing externally owned view. */ + renderPanel?: (value: Value) => ReactNode; value: Value; items: readonly TabItem[]; label: string; @@ -43,14 +46,8 @@ export function Tabs({ /** Brief visual confirmation of a tab arriving after a completed move. */ arrivalValue?: Value | undefined; }) { - return ( - onValueChange(nextValue as Value)} - > + const strip = ( + <> {items.map((item) => ( @@ -78,6 +75,33 @@ export function Tabs({ ))} {trailingAction} + + ); + return ( + onValueChange(nextValue as Value)} + > + {renderPanel ? ( +
+ {strip} +
+ ) : ( + strip + )} + {renderPanel && + items.map((item) => ( + + {renderPanel(item.value)} + + ))}
); } diff --git a/src/shared/design-system/ui/Tooltip.tsx b/src/shared/design-system/ui/Tooltip.tsx new file mode 100644 index 00000000..c70e3413 --- /dev/null +++ b/src/shared/design-system/ui/Tooltip.tsx @@ -0,0 +1,43 @@ +import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip"; +import { useId, useState, type ReactElement, type ReactNode } from "react"; + +/** A short supplementary hint. The trigger still owns its accessible name. */ +export function Tooltip({ + children, + content, +}: { + children: ReactElement<{ "aria-describedby"?: string }>; + content: ReactNode; +}) { + const id = useId(); + const [open, setOpen] = useState(false); + return ( + + + + + + {content} + + + + + ); +} diff --git a/src/shared/design-system/ui/compositions.test.tsx b/src/shared/design-system/ui/compositions.test.tsx new file mode 100644 index 00000000..cc2c031e --- /dev/null +++ b/src/shared/design-system/ui/compositions.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, test, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { Dialog } from "./Dialog"; +import { Button } from "./Button"; +import { Input } from "./Input"; +import { Field } from "./Field"; +import { Tooltip } from "./Tooltip"; +import { Tabs } from "./Tabs"; +import { NavigationItem } from "./NavigationItem"; + +afterEach(cleanup); + +test("pending dialogs reject close and Escape, then allow dismissal once released", async () => { + const user = userEvent.setup(); + const change = vi.fn(); + const content = (pending: boolean) => ( + + + + + + ); + const view = render(content(true)); + expect(screen.getByRole("dialog", { name: "Edit notes" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); + await user.click(screen.getByRole("textbox", { name: "Name" })); + await user.keyboard("{Escape}"); + expect(change).not.toHaveBeenCalled(); + view.rerender(content(false)); + await user.keyboard("{Escape}"); + expect(change).toHaveBeenCalledWith(false); +}); + +test("dialog close button closes the controlled frame", async () => { + const user = userEvent.setup(); + function Example() { + const [open, setOpen] = useState(false); + return ( + <> + + + + + + + + ); + } + render(); + await user.click(screen.getByRole("button", { name: "Edit notes" })); + expect( + screen.getByRole("dialog", { name: "Note details" }), + ).toHaveAccessibleDescription("Edit this example."); + await user.click(screen.getByRole("button", { name: "Close" })); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); +}); + +test("tabs link each trigger to its visible panel", async () => { + const user = userEvent.setup(); + function Example() { + const [value, setValue] = useState("notes"); + return ( + ( +

{tab === "notes" ? "Your notes" : "Recent activity"}

+ )} + /> + ); + } + render(); + const notes = screen.getByRole("tab", { name: "Notes" }); + expect(notes).toHaveAttribute( + "aria-controls", + screen.getByRole("tabpanel").id, + ); + expect(screen.getByRole("tabpanel")).toHaveAccessibleName("Notes"); + await user.click(screen.getByRole("tab", { name: "Activity" })); + expect(screen.getByRole("tabpanel")).toHaveAccessibleName("Activity"); + expect(screen.getByRole("tabpanel")).toHaveTextContent("Recent activity"); + expect(screen.queryByText("Your notes")).not.toBeInTheDocument(); +}); + +test("navigation keeps warm-read events and data attributes on the focusable button", async () => { + const user = userEvent.setup(); + const warm = vi.fn(); + const select = vi.fn(); + render( + , + ); + const row = screen.getByRole("button", { name: "Notes" }); + expect(row).toHaveAttribute("data-channel-id", "example"); + expect(row).toHaveAttribute("aria-current", "page"); + await user.hover(row); + expect(warm).toHaveBeenCalled(); + await user.click(row); + expect(select).toHaveBeenCalledTimes(1); +}); + +test("tooltip adds a dismissible hint without replacing the control name", async () => { + const user = userEvent.setup(); + render( + + + , + ); + const button = screen.getByRole("button", { name: "Create note" }); + await user.hover(button); + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "Create a new note", + ); + await user.keyboard("{Escape}"); + await waitFor(() => + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(), + ); + expect(button).toHaveAccessibleName("Create note"); +}); diff --git a/src/shared/design-system/ui/registry.ts b/src/shared/design-system/ui/registry.ts index a75d6beb..704f5785 100644 --- a/src/shared/design-system/ui/registry.ts +++ b/src/shared/design-system/ui/registry.ts @@ -38,6 +38,11 @@ export const BASE_UI_PARTS = { docs: "accordion", module: "@base-ui/react/accordion", }, + tooltip: { + name: "Tooltip", + docs: "tooltip", + module: "@base-ui/react/tooltip", + }, dialog: { name: "Dialog", docs: "dialog", module: "@base-ui/react/dialog" }, popover: { name: "Popover", @@ -89,6 +94,32 @@ export type ComponentDefinition = { }; export const COMPONENTS: readonly ComponentDefinition[] = [ + { + slug: "tooltip", + name: "Tooltip", + purpose: "A short hint for an already labelled control.", + behavior: "Base UI owns focus, positioning and dismissal", + variants: ["default"], + status: "proposed", + collection: "components", + source: "shared/design-system/ui/Tooltip.tsx", + baseUi: [BASE_UI_PARTS.tooltip], + composes: [], + }, + + { + slug: "dialog", + name: "Dialog", + purpose: "A shared modal frame with title, content and actions.", + behavior: "Base UI owns focus, positioning and dismissal", + variants: ["default"], + status: "proposed", + collection: "components", + source: "shared/design-system/ui/Dialog.tsx", + baseUi: [BASE_UI_PARTS.dialog], + composes: ["icon-button"], + }, + { slug: "checkbox", name: "Checkbox", diff --git a/tests/fixtures/design-system/ui/componentSpecimens.tsx b/tests/fixtures/design-system/ui/componentSpecimens.tsx index 01f79e9f..47d7dc05 100644 --- a/tests/fixtures/design-system/ui/componentSpecimens.tsx +++ b/tests/fixtures/design-system/ui/componentSpecimens.tsx @@ -1,3 +1,5 @@ +import { Dialog } from "../../../../src/shared/design-system/ui/Dialog"; +import { Tooltip } from "../../../../src/shared/design-system/ui/Tooltip"; import { Field } from "../../../../src/shared/design-system/ui/Field"; import { Input } from "../../../../src/shared/design-system/ui/Input"; import { Textarea } from "../../../../src/shared/design-system/ui/Textarea"; @@ -492,6 +494,9 @@ function TabsSpecimen() { value={panelDestination} items={DESTINATIONS} label="Prototype destinations on a panel" + renderPanel={(value) => ( +

{value} content

+ )} onValueChange={setPanelDestination} variant="panel" /> @@ -876,7 +881,42 @@ function SelectSpecimen() { ); } +function DialogSpecimen() { + const [open, setOpen] = useState(false); + return ( + + + + + + + } + > + + + + + + ); +} + export const COMPONENT_SPECIMENS: Record ReactNode> = { + dialog: DialogSpecimen, + tooltip: () => ( + + + } /> + + + ), field: () => ( Date: Thu, 17 Sep 2026 09:00:49 -0400 Subject: [PATCH 2/3] Establish signer order in shared cooldown regression Signed-off-by: Arjun Mahanti --- src/features/relay/signed-boundary.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts index 5eb8633f..56b49e6d 100644 --- a/src/features/relay/signed-boundary.test.ts +++ b/src/features/relay/signed-boundary.test.ts @@ -115,6 +115,9 @@ it("a signer already waiting cannot bypass a newly learned shared cooldown", asy "relay", ); const one = t.query([{ kinds: [0], limit: 1 }]).catch((e) => e); + // Digest preparation runs on real crypto threads. Establish which request is + // waiting first before releasing pending[0]; completion order is otherwise free. + await vi.waitFor(() => expect(pending).toHaveLength(1)); const two = t.query([{ kinds: [0], limit: 2 }]).catch((e) => e); await vi.advanceTimersByTimeAsync(600); await vi.waitFor(() => expect(pending).toHaveLength(2)); From 338bbb5be64cb9bcd312ca153cc49ce402f0332f Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Thu, 17 Sep 2026 10:10:22 -0400 Subject: [PATCH 3/3] Expose modal state to shortcut guards and stabilize channel targeting Signed-off-by: Arjun Mahanti --- src/shared/design-system/ui/Dialog.tsx | 1 + src/shared/design-system/ui/compositions.test.tsx | 4 ++++ tests/browser/navigation-thread-history.spec.mjs | 6 +++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/shared/design-system/ui/Dialog.tsx b/src/shared/design-system/ui/Dialog.tsx index 57620cc2..103ea2d0 100644 --- a/src/shared/design-system/ui/Dialog.tsx +++ b/src/shared/design-system/ui/Dialog.tsx @@ -48,6 +48,7 @@ export function Dialog({ diff --git a/src/shared/design-system/ui/compositions.test.tsx b/src/shared/design-system/ui/compositions.test.tsx index cc2c031e..f540236a 100644 --- a/src/shared/design-system/ui/compositions.test.tsx +++ b/src/shared/design-system/ui/compositions.test.tsx @@ -31,6 +31,10 @@ test("pending dialogs reject close and Escape, then allow dismissal once release ); const view = render(content(true)); expect(screen.getByRole("dialog", { name: "Edit notes" })).toBeVisible(); + expect(screen.getByRole("dialog", { name: "Edit notes" })).toHaveAttribute( + "aria-modal", + "true", + ); expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); await user.click(screen.getByRole("textbox", { name: "Name" })); await user.keyboard("{Escape}"); diff --git a/tests/browser/navigation-thread-history.spec.mjs b/tests/browser/navigation-thread-history.spec.mjs index eb45cf5a..2ae5de54 100644 --- a/tests/browser/navigation-thread-history.spec.mjs +++ b/tests/browser/navigation-thread-history.spec.mjs @@ -36,7 +36,11 @@ test("Back restores each thread visit before the previous channel", async ({ ); await expect(panel.getByText("Thread root 1", { exact: true })).toBeVisible(); expect(await openThread.evaluate((node) => node.isConnected)).toBe(true); - await page.getByRole("button", { name: "Beta", exact: true }).click(); + // The accessible name also includes the unread count once catch-up finishes. + await page + .getByRole("navigation", { name: "Subscribed channels" }) + .getByRole("button", { name: /^Beta(?: |$)/ }) + .click(); await expect( page.getByRole("textbox", { name: "Message #Beta", exact: true }), ).toBeVisible();