From 0886e0527e5a4b942db16d01f16c8fdf7d8e180d Mon Sep 17 00:00:00 2001
From: Sauer
Date: Thu, 12 Nov 2026 22:26:06 +0100
Subject: [PATCH] refactor(web): menus, choices, the phone menu, tabs and
meters on Base UI
Every remaining hand-built widget moves onto Base UI parts, imported only
inside components/ui, with the look unchanged:
- The request list's context menu (Base UI ContextMenu, acting on the row
the opening event came from, a long press included) and the body's Copy
menu and ops Saved replies picker (DropdownMenu in a new `list` look, not
modal, as before).
- Every pick-one choice is a RadioGroup: the segmented control, request
filters, body, preview, text and JSON view switches, theme, team plan,
ticket category, blocking and rating, the changelog filter and the ops
pickers. View switches keep their hairline edge.
- The settings switch is a Base UI Switch; usage bars are Meters.
- The phone site menu is a Popover kept in the header's stacking context;
the docs contents on phones are a Dialog, unmounted while closed, closed
at the md breakpoint, with a close control inside for touch screen
readers.
- The dashboard guide's sections, the MDX Tabs and the ops sharing
sections are Base UI Tabs.
- The dashboard's shortcut keys leave an open menu or dialog alone.
A test fails on a hand-written widget role outside components/ui.
Co-Authored-By: Claude Opus 5.5
---
AGENTS.md | 2 +-
.../(settings)/account/appearance/page.tsx | 17 +-
apps/web/app/changelog/page.tsx | 18 +-
apps/web/app/dashboard/page.tsx | 4 +
.../web/base-ui-menus-and-choices.json | 10 +
.../components/account/shares/shares-page.tsx | 10 +-
.../dashboard/dashboard-guide-dialog.tsx | 36 +-
.../web/components/dashboard/email-detail.tsx | 55 +-
.../components/dashboard/request-detail.tsx | 94 ++-
.../web/components/dashboard/request-list.tsx | 564 ++++++++----------
.../dashboard/settings-primitives.tsx | 58 +-
apps/web/components/docs/mdx/tabs.tsx | 35 +-
apps/web/components/docs/search-modal.tsx | 24 +-
apps/web/components/docs/sidebar.tsx | 91 +--
apps/web/components/nav/site-header.tsx | 394 ++++++------
apps/web/components/ops/inbox.tsx | 172 +++---
.../components/ops/refunds-and-replies.tsx | 20 +-
apps/web/components/ops/sharing.tsx | 58 +-
apps/web/components/ops/view-as.tsx | 36 +-
.../components/settings/allowance-meters.tsx | 10 +-
apps/web/components/settings/quota-meter.tsx | 10 +-
.../components/support/new-ticket-form.tsx | 35 +-
apps/web/components/support/ticket-view.tsx | 17 +-
.../components/teams/team-billing-panel.tsx | 53 +-
apps/web/components/ui/context-menu.tsx | 44 ++
apps/web/components/ui/dialog.tsx | 13 +
apps/web/components/ui/dropdown-menu.tsx | 58 +-
apps/web/components/ui/meter.tsx | 12 +
apps/web/components/ui/popover.tsx | 14 +
apps/web/components/ui/radio-group.tsx | 31 +
apps/web/components/ui/switch.tsx | 14 +
apps/web/lib/retired-classes.test.ts | 26 +
apps/web/tests/e2e/email-fields.spec.ts | 2 +-
apps/web/tests/e2e/settings.spec.ts | 4 +-
apps/web/tests/e2e/stored-xss.spec.ts | 6 +-
35 files changed, 1115 insertions(+), 932 deletions(-)
create mode 100644 apps/web/changelog/web/base-ui-menus-and-choices.json
create mode 100644 apps/web/components/ui/context-menu.tsx
create mode 100644 apps/web/components/ui/meter.tsx
create mode 100644 apps/web/components/ui/popover.tsx
create mode 100644 apps/web/components/ui/radio-group.tsx
create mode 100644 apps/web/components/ui/switch.tsx
diff --git a/AGENTS.md b/AGENTS.md
index 943f16bde..8928d81a1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -147,7 +147,7 @@ Env vars are validated with zod in `apps/web/lib/env.ts` and loaded in `apps/rec
- **Fake secrets stay in test paths.** Push protection is on. Test fixtures that look like real credentials (Stripe-style `whsec_` secrets, signed vectors) belong under the paths in `.github/secret_scanning.yml`, which scanning skips; add a path there rather than bypassing a block, and never put a fixture like that in production code.
- **A release label and a changelog file on every PR; the web version is given at deploy.** Every pull request carries one label: `release: patch` (fixes and small features), `release: minor` (significant features), `release: major` (reserved for 1.0), or `release: none` (nothing shipped to the web app, receiver or MX host: SDK-only, CI, tooling). Unless it is `none`, it adds its own changelog file to `apps/web/changelog/web/` (a title and items, never a version; see the README there). The required `Release label` check enforces both. Never write a web version in a PR or a commit title: a shared version line made every pair of open PRs conflict. The ops repository's Deploy workflow runs `apps/web/scripts/release.mjs plan`, which takes the newest `web-v*` tag the deployed commit contains, applies the highest bump among the PRs merged since, and writes `apps/web/lib/changelog-release.json` for the build (in the repository it holds no version, so local and CI builds report `dev`; never commit a generated copy); a production deploy then tags the commit `web-vX.Y.Z` with the release GitHub App. A dry run is `X.Y.Z-dryrun` and is not tagged, deploying a tag again keeps its version, and an untagged commit behind the newest tag is refused (roll back by deploying a tag). Tag rulesets let only admins and the release app (`webhooks-cc-release`) create `web-v*` tags, and only admins move or delete them. Web entries up to 0.65.1 were numbered by hand and stay in `changelog.ts`. CLI, SDK, and MCP releases bump `CLI_VERSION`, `SDK_VERSION`, or `MCP_VERSION` and add an entry on their own track when the tag is cut (`v*`, `sdk-v*`, `mcp-v*`). A unit test keeps the SDK and MCP constants in sync with the package versions. The Python SDK's version lives in `packages/sdk-python/pyproject.toml` and `src/webhooks_cc/__init__.py`; a `sdk-python-v*` tag publishes it to PyPI through trusted publishing (no token), and the workflow refuses a tag that does not match the version. The pytest plugin's version lives in `packages/pytest-webhooks-cc/pyproject.toml` and `src/pytest_webhooks_cc/__init__.py`, and a `pytest-plugin-v*` tag publishes it the same way; it depends on the SDK from PyPI, so publish the SDK first when the plugin needs something new from it. The Go SDK's version is `Version` in `packages/sdk-go/doc.go`; Go modules are published by their tags, so a release is the tag `packages/sdk-go/v` (the module sits in a subdirectory), pushed after the web release whose `/go` route serves the `go-import` tag; the release workflow checks the tag against `Version` and has the module proxy fetch it.
- **Tests live next to the code they cover.** Unit tests as `*.test.ts` beside the source, web integration suites in `apps/web/tests/integration/`, Rust tests in each crate. Scratch verification scripts stay out of the repo.
-- **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus, selects and the slider are its parts with our classes; tabs, collapsibles and the accordion are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar or show-and-hide panel on them, not by hand; and `Button` takes base-ui's `render` prop to draw as a link (` }>`); there is no `asChild`. A menu item acts in `onClick` (a Radix-style `onSelect` is only the DOM's text-selection event there), a Select's trigger shows labels from its `SelectItem`s, and the docs that ship with the package (`node_modules/@base-ui/react/docs/`) are the reference. Check new UI in light and dark, and reuse those primitives rather than introducing new styling patterns.
+- **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus (`look="list"` for the dashboard's bordered list menus), the context menu, selects and the slider are its parts with our classes; tabs, collapsibles, the accordion, radio groups, the switch, the popover, the meter and a dialog drawn its own way (`DialogPortal`, `DialogBackdrop`, `DialogPopup`) are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar, show-and-hide panel, pick-one choice (a segmented control too) or usage bar on them, not by hand, and import Base UI only inside `components/ui`; a `RadioGroup` reports changes through `onValueChange`, never a radio's `onClick`, and leaves a hidden input beside each radio, so space its radios with `gap-*`; `lib/retired-classes.test.ts` fails on a hand-written widget role (`role="radio"`, `"menu"`, `"dialog"`, `aria-modal` and the like) outside `components/ui`, the cookie banner (a non-modal notice with no behaviour of its own) being the one file allowed; the Copy and Saved replies list menus are not modal (`modal={false}`), so a click outside one still does what it clicks, as the menus they replaced did, while the context menu is modal like the browser's own (Base UI allows nothing else); and `Button` takes base-ui's `render` prop to draw as a link (` }>`); there is no `asChild`. A menu item acts in `onClick` (a Radix-style `onSelect` is only the DOM's text-selection event there), a Select's trigger shows labels from its `SelectItem`s, and the docs that ship with the package (`node_modules/@base-ui/react/docs/`) are the reference. Check new UI in light and dark, and reuse those primitives rather than introducing new styling patterns.
- **Formatting.** Prettier and ESLint are enforced in CI; run `pnpm format` before committing. `apps/web/public/email-templates/` is excluded because Prettier breaks Go template actions.
- **No em dashes** anywhere: code, comments, docs, commit messages.
diff --git a/apps/web/app/(settings)/account/appearance/page.tsx b/apps/web/app/(settings)/account/appearance/page.tsx
index 6c2db3ffe..1610f00ec 100644
--- a/apps/web/app/(settings)/account/appearance/page.tsx
+++ b/apps/web/app/(settings)/account/appearance/page.tsx
@@ -4,6 +4,7 @@ import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
import { useTheme, type Theme } from "@/components/providers/theme-provider";
import { Panel, SettingsPageHeader } from "@/components/settings/panel";
import { cn } from "@/lib/utils";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
const THEMES: { value: Theme; label: string; icon: LucideIcon }[] = [
{ value: "light", label: "Light", icon: Sun },
@@ -26,8 +27,9 @@ export default function AppearancePage() {
title="Theme"
description="Light and Dark stay as picked; System follows your operating system and changes with it."
>
- setTheme(next as Theme)}
aria-label="Theme"
className="inline-flex overflow-hidden rounded-md border"
>
@@ -35,12 +37,9 @@ export default function AppearancePage() {
const Icon = option.icon;
const active = theme === option.value;
return (
- setTheme(option.value)}
+ value={option.value}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
index > 0 && "border-l border-border",
@@ -49,10 +48,10 @@ export default function AppearancePage() {
>
{option.label}
-
+
);
})}
-
+
>
);
diff --git a/apps/web/app/changelog/page.tsx b/apps/web/app/changelog/page.tsx
index c540819e5..dc243e50a 100644
--- a/apps/web/app/changelog/page.tsx
+++ b/apps/web/app/changelog/page.tsx
@@ -12,6 +12,7 @@ import {
TRACK_LABELS,
type ChangelogTrack,
} from "@/lib/changelog";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
const TRACKS: (ChangelogTrack | "all")[] = ["all", "web", "cli", "sdk", "python", "go", "mcp"];
@@ -59,21 +60,26 @@ export default function ChangelogPage() {
-
+ setActiveTrack(next as typeof activeTrack)}
+ aria-label="Releases to show"
+ className="flex gap-2 mb-8 flex-wrap"
+ >
{TRACKS.map((track) => (
- setActiveTrack(track)}
- className={`px-3 py-1.5 text-xs font-bold border border-border rounded-md transition-colors ${
+ value={track}
+ className={`px-3 py-1.5 text-xs font-bold border border-border rounded-md transition-colors focus-visible:outline-offset-2 ${
activeTrack === track
? "bg-selected text-selected-foreground"
: "bg-background text-foreground hover:bg-muted"
}`}
>
{track === "all" ? "All" : TRACK_LABELS[track]}
-
+
))}
-
+
{filtered.map((entry) => (
diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx
index 8f63fa55d..865788c8c 100644
--- a/apps/web/app/dashboard/page.tsx
+++ b/apps/web/app/dashboard/page.tsx
@@ -1713,6 +1713,10 @@ export default function DashboardPage() {
// Don't intercept when typing in inputs
if (isInput) return;
+ // An open menu or dialog has the keyboard: a letter there picks an item
+ // (a menu's type-ahead) or types, and must not act on the page behind it.
+ if ((e.target as HTMLElement)?.closest?.("[role=menu], [role=dialog], [role=alertdialog]"))
+ return;
// Don't intercept when modifiers are held (allow browser shortcuts)
if (e.ctrlKey || e.metaKey || e.altKey) return;
diff --git a/apps/web/changelog/web/base-ui-menus-and-choices.json b/apps/web/changelog/web/base-ui-menus-and-choices.json
new file mode 100644
index 000000000..75acd245a
--- /dev/null
+++ b/apps/web/changelog/web/base-ui-menus-and-choices.json
@@ -0,0 +1,10 @@
+{
+ "title": "Menus, choices and meters on Base UI",
+ "items": [
+ "The request list's menu (right click, the keyboard's menu key or a long press) and the body's Copy menu work from the keyboard: arrow keys move through them, Enter picks, Escape closes, and the dashboard's shortcut keys leave an open menu alone. Like the browser's own, the right-click menu closes on a click outside without acting on it",
+ "Pick-one choices (filters, body and preview views, theme, plan, ticket category and rating) are one stop for Tab, and the arrow keys move the choice",
+ "The dashboard guide's sections are tabs: the arrow keys move between them",
+ "On phones, the docs contents keep focus inside while open, and once closed no longer hold links Tab could reach",
+ "Usage bars are announced as meters"
+ ]
+}
diff --git a/apps/web/components/account/shares/shares-page.tsx b/apps/web/components/account/shares/shares-page.tsx
index be8daf618..7055bf644 100644
--- a/apps/web/components/account/shares/shares-page.tsx
+++ b/apps/web/components/account/shares/shares-page.tsx
@@ -50,6 +50,7 @@ import { CommandBlock } from "./command-block";
import { NamesPanel } from "./names-panel";
import { QrDialog } from "./qr-dialog";
import { RequestLog } from "./request-log";
+import { Meter } from "@/components/ui/meter";
const REFRESH_MS = 10_000;
const INSTALL = "curl -fsSL https://webhooks.cc/install.sh | sh";
@@ -507,11 +508,8 @@ function DataMeter({
Resets {longDate(resetsAt)}
-
@@ -519,7 +517,7 @@ function DataMeter({
className={cn("h-full", near ? "bg-destructive" : "bg-primary")}
style={{ width: `${Math.max(percent, bytes > 0 ? 1 : 0)}%` }}
/>
-
+
);
}
diff --git a/apps/web/components/dashboard/dashboard-guide-dialog.tsx b/apps/web/components/dashboard/dashboard-guide-dialog.tsx
index 4bde264b6..c5f1e959d 100644
--- a/apps/web/components/dashboard/dashboard-guide-dialog.tsx
+++ b/apps/web/components/dashboard/dashboard-guide-dialog.tsx
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
-import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
@@ -32,6 +31,7 @@ import {
import Link from "next/link";
import { WEB_VERIFICATION_PROVIDER_OPTIONS } from "@/lib/provider-catalog";
import { Button } from "@/components/ui/button";
+import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
interface Section {
id: string;
@@ -544,40 +544,40 @@ export function DashboardGuideDialog({
)}
{/* As tall as the longer of the two columns, up to 85vh; each column scrolls past that. */}
-
+
setActiveSection(next as string)}
+ orientation="vertical"
+ className="flex max-h-[85vh] min-h-0"
+ >
{/* Sidebar */}
-
+
Dashboard guide
-
+
{SECTIONS.map((section) => (
- setActiveSection(section.id)}
- className={cn(
- "w-full px-4 py-1.5 text-left text-xs font-bold flex items-center gap-2 cursor-pointer transition-colors",
- activeSection === section.id
- ? "bg-selected text-selected-foreground"
- : "text-muted-foreground hover:text-foreground hover:bg-muted"
- )}
+ value={section.id}
+ className="w-full px-4 py-1.5 text-left text-xs font-bold flex items-center gap-2 cursor-pointer transition-colors data-active:bg-selected data-active:text-selected-foreground not-data-active:text-muted-foreground not-data-active:hover:text-foreground not-data-active:hover:bg-muted"
>
{section.icon}
{section.title}
-
+
))}
-
-
+
+
{/* Content */}
-
+
{current.icon}
{current.title}
{current.content}
-
-
+
+
);
diff --git a/apps/web/components/dashboard/email-detail.tsx b/apps/web/components/dashboard/email-detail.tsx
index ef155cb84..6c8d6ed79 100644
--- a/apps/web/components/dashboard/email-detail.tsx
+++ b/apps/web/components/dashboard/email-detail.tsx
@@ -53,6 +53,7 @@ import { ParsedResult } from "./layouts-section";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CodeBox } from "@/components/ui/code";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
type EmailTab =
| "parsed"
@@ -973,12 +974,18 @@ function EmailJsonPane({
)}
-
+ setView(next as typeof view)}
+ aria-label="JSON view"
+ // A view switch keeps the light hairline it had as plain buttons,
+ // not the darker control edge globals.css gives a radio group.
+ className="flex overflow-hidden rounded-sm border border-border!"
+ >
{(["formatted", "tree"] as const).map((mode) => (
- setView(mode)}
+ value={mode}
className={cn(
"px-2 py-0.5 text-[11px] font-bold cursor-pointer transition-colors",
mode === "tree" && "border-l border-border",
@@ -988,9 +995,9 @@ function EmailJsonPane({
)}
>
{mode === "formatted" ? "Formatted" : "Tree"}
-
+
))}
-
+
{/* The width toggle; a phone shows the mobile width anyway. */}
-
setWidth(next as typeof width)}
aria-label="Preview width"
- className="flex overflow-hidden rounded-sm border"
+ // A view switch keeps the light hairline it had as plain buttons.
+ className="flex overflow-hidden rounded-sm border border-border!"
>
{(["desktop", "mobile"] as const).map((mode) => (
- setWidth(mode)}
+ value={mode}
className={cn(
"px-2.5 py-1 text-[11px] font-bold cursor-pointer transition-colors capitalize",
mode === "mobile" && "border-l border-border",
@@ -1648,9 +1655,9 @@ export function EmailDetail({
)}
>
{mode}
-
+
))}
-
+
{email.truncated.html && (
@@ -1669,9 +1676,11 @@ export function EmailDetail({
{reply && (
-
setTextView(next as typeof textView)}
+ // A view switch keeps the light hairline it had as plain buttons.
+ className="flex overflow-hidden rounded-sm border border-border!"
aria-label="Text to show"
>
{(
@@ -1680,11 +1689,9 @@ export function EmailDetail({
["reply", "Reply only"],
] as const
).map(([mode, label]) => (
- setTextView(mode)}
- aria-pressed={textView === mode}
+ value={mode}
className={cn(
"px-2.5 py-1 max-md:py-1.5 text-[11px] font-bold cursor-pointer transition-colors whitespace-nowrap capitalize",
mode === "reply" && "border-l border-border",
@@ -1694,9 +1701,9 @@ export function EmailDetail({
)}
>
{label}
-
+
))}
-
+
)}
{email.textFromHtml && (
diff --git a/apps/web/components/dashboard/request-detail.tsx b/apps/web/components/dashboard/request-detail.tsx
index 0b248e2cd..1bd2eef6b 100644
--- a/apps/web/components/dashboard/request-detail.tsx
+++ b/apps/web/components/dashboard/request-detail.tsx
@@ -36,7 +36,14 @@ import { ResponseAnswer } from "./response-answer";
import { LiveResponseView } from "./live-response";
import { readLiveResponse } from "@/lib/live-response";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { CodeBox } from "@/components/ui/code";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
/** Any request shape that has the fields needed for display. */
export type DisplayableRequest = Request | ClickHouseRequest;
@@ -359,13 +366,18 @@ export function RequestDetail({
{getFormatLabel(bodyFormat)}
{bodyViews.length > 1 && (
-
+ setBodyView(next as typeof bodyView)}
+ aria-label="Body view"
+ // A view switch keeps the light hairline it had as plain buttons,
+ // not the darker control edge globals.css gives a radio group.
+ className="flex items-center overflow-hidden rounded-sm border border-border!"
+ >
{bodyViews.map((view, i) => (
- setBodyView(view)}
+ value={view}
className={cn(
"px-2.5 py-1 max-md:py-1.5 text-[11px] font-bold cursor-pointer transition-colors",
i > 0 && "border-l border-border",
@@ -375,9 +387,9 @@ export function RequestDetail({
)}
>
{BODY_VIEW_LABELS[view]}
-
+
))}
-
+
)}
{request.body && bodyView !== "schema" && !bodyBytes && (
@@ -569,27 +581,6 @@ function BodyCopyDropdown({
onCopy: (text: string, key: string) => void;
copied: string | null;
}) {
- const [open, setOpen] = useState(false);
- const ref = useRef(null);
-
- useEffect(() => {
- if (!open) return;
- function handleClickOutside(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- function handleEscape(e: KeyboardEvent) {
- if (e.key === "Escape") setOpen(false);
- }
- document.addEventListener("mousedown", handleClickOutside);
- document.addEventListener("keydown", handleEscape);
- return () => {
- document.removeEventListener("mousedown", handleClickOutside);
- document.removeEventListener("keydown", handleEscape);
- };
- }, [open]);
-
const csvBody = useMemo(() => jsonToCsvValue(body), [body]);
const isCopied =
copied === "body" || copied === "body-formatted" || copied === "ts" || copied === "csv";
@@ -609,35 +600,34 @@ function BodyCopyDropdown({
}, [body, formattedBody, tsInterface, csvBody]);
return (
-
-
setOpen(!open)}
- >
- {isCopied ? : }
- {isCopied ? "Copied!" : "Copy"}
-
-
- {open && (
-
+
+ {/* Not modal: a click elsewhere closes it and still lands, as before. */}
+
+
+ }
+ >
+ {isCopied ? : }
+ {isCopied ? "Copied!" : "Copy"}
+
+
+
{options.map((opt, i) => (
- {
- onCopy(opt.value, opt.key);
- setOpen(false);
- }}
- className={cn(
- "w-full px-3 py-2 text-left text-xs font-bold hover:bg-muted cursor-pointer transition-colors",
- i < options.length - 1 && "border-b border-border"
- )}
+ look="list"
+ onClick={() => onCopy(opt.value, opt.key)}
+ className={cn(i < options.length - 1 && "border-b")}
>
{opt.label}
-
+
))}
-
- )}
+
+
);
}
diff --git a/apps/web/components/dashboard/request-list.tsx b/apps/web/components/dashboard/request-list.tsx
index d086291a7..0c9c24e5b 100644
--- a/apps/web/components/dashboard/request-list.tsx
+++ b/apps/web/components/dashboard/request-list.tsx
@@ -20,6 +20,13 @@ import {
Clock,
} from "lucide-react";
import { copyToClipboard } from "@/lib/clipboard";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
@@ -171,33 +178,21 @@ export function RequestList({
const internalSearchRef = useRef
(null);
const inputRef = searchInputRef ?? internalSearchRef;
- // Context menu
- const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; id: string } | null>(null);
- const ctxRef = useRef(null);
- useEffect(() => {
- if (!ctxMenu) return;
- function close(e: MouseEvent) {
- if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null);
+ // Context menu: it acts on the row that was pressed (by a right click, the
+ // keyboard's menu key or a long press). Pressed anywhere else in the list,
+ // such as a group heading, it offers only what needs no row.
+ const [ctxId, setCtxId] = useState(null);
+ const [ctxOpen, setCtxOpen] = useState(false);
+ const handleCtxOpenChange = useCallback((open: boolean, details: { event?: Event }) => {
+ if (open) {
+ const target = details.event?.target;
+ setCtxId(
+ (target instanceof Element
+ ? target.closest("[data-request-id]")?.dataset.requestId
+ : undefined) ?? null
+ );
}
- function esc(e: KeyboardEvent) {
- if (e.key === "Escape") setCtxMenu(null);
- }
- document.addEventListener("mousedown", close);
- document.addEventListener("keydown", esc);
- return () => {
- document.removeEventListener("mousedown", close);
- document.removeEventListener("keydown", esc);
- };
- }, [ctxMenu]);
-
- const handleContextMenu = useCallback((e: React.MouseEvent, id: string) => {
- e.preventDefault();
- // Clamp to viewport so the menu doesn't overflow off-screen
- const menuW = 180;
- const menuH = 160;
- const x = Math.min(e.clientX, window.innerWidth - menuW - 8);
- const y = Math.min(e.clientY, window.innerHeight - menuH - 8);
- setCtxMenu({ x: Math.max(0, x), y: Math.max(0, y), id });
+ setCtxOpen(open);
}, []);
// Pin/unpin split
@@ -324,7 +319,6 @@ export function RequestList({
tabIndex: tabStopId === id ? 0 : -1,
"data-request-id": id,
onClick: (e: React.MouseEvent) => handleRowClick(e, id),
- onContextMenu: (e: React.MouseEvent) => handleContextMenu(e, id),
onKeyDown: (e: React.KeyboardEvent) => handleRowKeyDown(e, id),
className: rowClass(id, isComparing),
});
@@ -353,318 +347,272 @@ export function RequestList({
) : null;
return (
-
- {/* Toolbar */}
-
- {onKindFilterChange ? (
-
- {kinds.map((kind, index) => (
+
+
+ {/* Toolbar */}
+
+ {onKindFilterChange ? (
+
onKindFilterChange(next as KindFilter)}
+ aria-label="Show"
+ className="flex min-w-0 overflow-hidden rounded-md border"
+ >
+ {kinds.map((kind, index) => (
+ 0 && "border-l border-border",
+ kindFilter === kind.id
+ ? "bg-selected text-selected-foreground"
+ : "bg-background hover:bg-muted"
+ )}
+ >
+ {kind.label}
+ {/* The count of what is shown, from the server, so only the picked kind has one. */}
+ {kindFilter === kind.id && (
+
+ {displayCount}
+
+ )}
+
+ ))}
+
+ ) : (
+
+ {displayCount} request{displayCount !== 1 ? "s" : ""}
+
+ )}
+
+ {onViewModeChange && (
onKindFilterChange(kind.id)}
- onKeyDown={(event) => {
- const step =
- event.key === "ArrowRight" || event.key === "ArrowDown"
- ? 1
- : event.key === "ArrowLeft" || event.key === "ArrowUp"
- ? -1
- : 0;
- if (!step) return;
- event.preventDefault();
- const next = kinds[(index + step + kinds.length) % kinds.length];
- onKindFilterChange(next.id);
- event.currentTarget.parentElement
- ?.querySelectorAll("[role=radio]")
- [(index + step + kinds.length) % kinds.length]?.focus();
- }}
- className={cn(
- "flex items-center gap-1.5 px-2.5 py-1 max-md:py-2 text-xs font-bold cursor-pointer transition-colors whitespace-nowrap",
- index > 0 && "border-l border-border",
- kindFilter === kind.id
- ? "bg-selected text-selected-foreground"
- : "bg-background hover:bg-muted"
- )}
+ onClick={() => onViewModeChange(viewMode === "list" ? "timeline" : "list")}
+ className="p-1.5 max-md:p-2.5 hover:bg-muted transition-colors cursor-pointer rounded-md border"
+ title={viewMode === "list" ? "Switch to timeline view" : "Switch to list view"}
+ aria-label={viewMode === "list" ? "Switch to timeline view" : "Switch to list view"}
+ aria-pressed={viewMode === "timeline"}
>
- {kind.label}
- {/* The count of what is shown, from the server, so only the picked kind has one. */}
- {kindFilter === kind.id && (
-
- {displayCount}
-
+ {viewMode === "list" ? (
+
+ ) : (
+
)}
- ))}
-
- ) : (
-
- {displayCount} request{displayCount !== 1 ? "s" : ""}
-
- )}
-
- {onViewModeChange && (
+ )}
onViewModeChange(viewMode === "list" ? "timeline" : "list")}
+ onClick={onToggleSort}
className="p-1.5 max-md:p-2.5 hover:bg-muted transition-colors cursor-pointer rounded-md border"
- title={viewMode === "list" ? "Switch to timeline view" : "Switch to list view"}
- aria-label={viewMode === "list" ? "Switch to timeline view" : "Switch to list view"}
- aria-pressed={viewMode === "timeline"}
+ title={sortNewest ? "Showing newest first" : "Showing oldest first"}
+ aria-label={
+ sortNewest
+ ? "Newest first. Switch to oldest first"
+ : "Oldest first. Switch to newest first"
+ }
>
- {viewMode === "list" ? (
-
- ) : (
-
- )}
+
- )}
-
-
-
-
-
- {liveMode ? "Live" : "Paused"}
-
-
-
-
- {/* Filter bar */}
-
- {kindFilter !== "email" && (
-
-
-
-
-
- {METHODS.map((m) => (
-
- {METHOD_LABELS[m]}
-
- ))}
-
-
- )}
-
-
- onSearchQueryChange(e.target.value)}
- aria-label="Search requests"
- placeholder={onKindFilterChange ? "Search" : "Search..."}
- className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground font-mono min-w-0"
- />
- {searchQuery && (
- onSearchQueryChange("")}
- aria-label="Clear search"
- className="text-muted-foreground hover:text-foreground cursor-pointer"
+ title={
+ liveMode
+ ? "Live mode: auto-selects new requests"
+ : "Review mode: new requests won't interrupt"
+ }
>
-
+
+ {liveMode ? "Live" : "Paused"}
- )}
+
-
-
- {/* New requests banner */}
- {/* Screen readers hear the count change as requests arrive. */}
-
- {displayCount} {displayCount === 1 ? itemNoun.slice(0, -1) : itemNoun}
-
- {!liveMode && (newCount ?? 0) > 0 && onJumpToNew && (
-
- {newCount ?? 0} new request{newCount !== 1 ? "s" : ""}
-
- )}
- {/* Compare hint */}
- {compareId && (
-
- Shift-click another request to compare
+ {/* Filter bar */}
+
+ {kindFilter !== "email" && (
+
+
+
+
+
+ {METHODS.map((m) => (
+
+ {METHOD_LABELS[m]}
+
+ ))}
+
+
+ )}
+
+
+ onSearchQueryChange(e.target.value)}
+ aria-label="Search requests"
+ placeholder={onKindFilterChange ? "Search" : "Search..."}
+ className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground font-mono min-w-0"
+ />
+ {searchQuery && (
+ onSearchQueryChange("")}
+ aria-label="Clear search"
+ className="text-muted-foreground hover:text-foreground cursor-pointer"
+ >
+
+
+ )}
+
- )}
- {/* Request rows */}
-
- {viewMode === "timeline" && timelineSlot ? (
- <>
- {timelineSlot}
- {/* The timeline pages like the list, so older matches stay reachable. */}
- {loadMore}
- >
- ) : searchLoading ? (
-
-
- Searching...
+ {/* New requests banner */}
+ {/* Screen readers hear the count change as requests arrive. */}
+
+ {displayCount} {displayCount === 1 ? itemNoun.slice(0, -1) : itemNoun}
+
+ {!liveMode && (newCount ?? 0) > 0 && onJumpToNew && (
+
+ {newCount ?? 0} new request{newCount !== 1 ? "s" : ""}
+
+ )}
+
+ {/* Compare hint */}
+ {compareId && (
+
+ Shift-click another request to compare
- ) : pinned.length === 0 &&
- unpinned.length === 0 &&
- talksEmail &&
- emailAddress &&
- kindFilter !== "http" &&
- !searchQuery &&
- methodFilter === "ALL" &&
- !hasMore &&
- !searchError ? (
- // Nothing filtered away: no email has arrived yet.
-
- ) : pinned.length === 0 && unpinned.length === 0 ? (
- <>
-
- {searchError
- ? "Search unavailable"
- : hasMore
- ? `None in the newest ${itemNoun}`
- : searchQuery
- ? // The detail pane explains an empty search and offers to clear it.
- "0 results"
- : `No matching ${itemNoun}`}
+ )}
+
+ {/* Request rows */}
+
+ {viewMode === "timeline" && timelineSlot ? (
+ <>
+ {timelineSlot}
+ {/* The timeline pages like the list, so older matches stay reachable. */}
+ {loadMore}
+ >
+ ) : searchLoading ? (
+
+
+ Searching...
- {/* A filter can match nothing loaded so far and still match older pages. */}
- {loadMore}
- >
- ) : (
- <>
-
- {pinned.length > 0 ? (
- <>
-
-
-
- Pinned
-
- {pinned.map((request) => renderRow(request))}
-
- {unpinned.length > 0 && (
-
+ ) : pinned.length === 0 &&
+ unpinned.length === 0 &&
+ talksEmail &&
+ emailAddress &&
+ kindFilter !== "http" &&
+ !searchQuery &&
+ methodFilter === "ALL" &&
+ !hasMore &&
+ !searchError ? (
+ // Nothing filtered away: no email has arrived yet.
+
+ ) : pinned.length === 0 && unpinned.length === 0 ? (
+ <>
+
+ {searchError
+ ? "Search unavailable"
+ : hasMore
+ ? `None in the newest ${itemNoun}`
+ : searchQuery
+ ? // The detail pane explains an empty search and offers to clear it.
+ "0 results"
+ : `No matching ${itemNoun}`}
+
+ {/* A filter can match nothing loaded so far and still match older pages. */}
+ {loadMore}
+ >
+ ) : (
+ <>
+
+ {pinned.length > 0 ? (
+ <>
+
- All {itemNoun}
+
+ Pinned
- {unpinned.map((request) => renderRow(request))}
+ {pinned.map((request) => renderRow(request))}
- )}
- >
- ) : (
- unpinned.map((request) => renderRow(request))
- )}
-
+ {unpinned.length > 0 && (
+
+
+ All {itemNoun}
+
+ {unpinned.map((request) => renderRow(request))}
+
+ )}
+ >
+ ) : (
+ unpinned.map((request) => renderRow(request))
+ )}
+
- {loadMore}
- >
- )}
-
+ {loadMore}
+ >
+ )}
+
- {/* Context menu */}
- {ctxMenu && (
-
- {onTogglePin && (
- {
- onTogglePin(ctxMenu.id);
- setCtxMenu(null);
- }}
- className="w-full px-3 py-2 text-left text-xs font-bold hover:bg-muted cursor-pointer transition-colors border-b flex items-center gap-2"
- >
-
- {pinnedIds?.has(ctxMenu.id) ? "Unpin" : "Pin"}
-
+
+ {ctxId && onTogglePin && (
+ onTogglePin(ctxId)}>
+
+ {pinnedIds?.has(ctxId) ? "Unpin" : "Pin"}
+
)}
- {onCompareSelect && (
- {
- onCompareSelect(ctxMenu.id);
- setCtxMenu(null);
- }}
- className="w-full px-3 py-2 text-left text-xs font-bold hover:bg-muted cursor-pointer transition-colors border-b flex items-center gap-2"
- >
+ {ctxId && onCompareSelect && (
+ onCompareSelect(ctxId)}>
Compare
-
+
)}
- {
- void copyToClipboard(ctxMenu.id);
- setCtxMenu(null);
- }}
- className="w-full px-3 py-2 text-left text-xs font-bold hover:bg-muted cursor-pointer transition-colors border-b flex items-center gap-2"
- >
-
- Copy ID
-
- {
- toggleTimestampMode();
- setCtxMenu(null);
- }}
- className="w-full px-3 py-2 text-left text-xs font-bold hover:bg-muted cursor-pointer transition-colors flex items-center gap-2"
- >
+ {ctxId && (
+ void copyToClipboard(ctxId)}>
+
+ Copy ID
+
+ )}
+
{relativeTime ? "Show clock times" : "Show relative times"}
-
-
- )}
-
+
+
+
+
);
function renderRow(request: AnyRequestSummary) {
diff --git a/apps/web/components/dashboard/settings-primitives.tsx b/apps/web/components/dashboard/settings-primitives.tsx
index 372674835..e285f814f 100644
--- a/apps/web/components/dashboard/settings-primitives.tsx
+++ b/apps/web/components/dashboard/settings-primitives.tsx
@@ -1,12 +1,14 @@
"use client";
-import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/components/providers/supabase-auth-provider";
import { emitDashboardEndpointsChanged, updateDashboardEndpoint } from "@/lib/dashboard-api";
import { SectionIdentityContext, useReportDirty } from "./dirty-sections";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
+import { SwitchRoot, SwitchThumb } from "@/components/ui/switch";
/**
* The building blocks every section of the endpoint settings shares: the
@@ -252,15 +254,15 @@ export function Switch({
describedBy?: string;
}) {
return (
-
}
+ checked={checked}
+ onCheckedChange={(next) => onChange(next)}
aria-label={label}
aria-describedby={describedBy}
disabled={disabled}
- onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 items-center border rounded-full cursor-pointer transition-colors motion-reduce:transition-none",
"disabled:cursor-not-allowed disabled:opacity-60",
@@ -268,13 +270,13 @@ export function Switch({
checked ? "bg-primary" : "bg-muted border-input"
)}
>
-
-
+
);
}
@@ -327,7 +329,7 @@ export interface SegmentedOption
{
/**
* The segmented control (the JSON pane's Formatted | Tree pattern) as a
- * radio group: one tab stop, arrow keys move the choice, Home and End jump.
+ * radio group: one tab stop, and the arrow keys move the choice.
*/
export function Segmented({
name,
@@ -344,29 +346,13 @@ export function Segmented({
size?: "sm" | "xs";
className?: string;
}) {
- const onKeyDown = (event: KeyboardEvent, index: number) => {
- let next: number | null = null;
- if (event.key === "ArrowRight" || event.key === "ArrowDown") {
- next = (index + 1) % options.length;
- } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
- next = (index - 1 + options.length) % options.length;
- } else if (event.key === "Home") {
- next = 0;
- } else if (event.key === "End") {
- next = options.length - 1;
- }
- if (next === null) return;
- event.preventDefault();
- onChange(options[next].value);
- const group = event.currentTarget.parentElement;
- group?.querySelectorAll("[role=radio]")[next]?.focus();
- };
// More than three choices do not fit a phone's width side by side, and a
// scrolling strip hides some of them, so they stack there instead.
const stack = options.length > 3;
return (
- onChange(next as T)}
aria-label={name}
className={cn(
"inline-flex max-w-full overflow-x-auto [scrollbar-width:none] rounded-sm border bg-background",
@@ -377,14 +363,12 @@ export function Segmented({
{options.map((option, index) => {
const checked = option.value === value;
return (
- onChange(option.value)}
- onKeyDown={(event) => onKeyDown(event, index)}
+ value={option.value}
+ // Picking the option already picked still reports it, as before
+ // (a template choice refills an emptied body).
+ onClick={checked ? () => onChange(option.value) : undefined}
className={cn(
"font-bold whitespace-nowrap cursor-pointer transition-colors motion-reduce:transition-none",
size === "sm" ? "px-3 py-1.5 text-xs" : "px-2.5 py-1 text-[11px]",
@@ -408,10 +392,10 @@ export function Segmented({
{option.count}
>
)}
-
+
);
})}
-
+
);
}
diff --git a/apps/web/components/docs/mdx/tabs.tsx b/apps/web/components/docs/mdx/tabs.tsx
index 29f0ddb54..f59359d52 100644
--- a/apps/web/components/docs/mdx/tabs.tsx
+++ b/apps/web/components/docs/mdx/tabs.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, Children, isValidElement, type ReactElement } from "react";
-import { cn } from "@/lib/utils";
+import { Tabs as TabsRoot, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
export function Tabs({ items, children }: { items?: string[]; children: React.ReactNode }) {
const [active, setActive] = useState(0);
@@ -13,28 +13,29 @@ export function Tabs({ items, children }: { items?: string[]; children: React.Re
const labels = items ?? tabs.map((tab, i) => tab.props.label ?? `Tab ${i + 1}`);
return (
-
-
+ setActive(next as number)}
+ className="my-6 rounded-lg border border-border shadow-surface overflow-hidden"
+ >
+
{labels.map((label, i) => (
- setActive(i)}
- type="button"
- className={cn(
- "px-4 py-2 text-sm font-bold transition-colors cursor-pointer",
- i === active
- ? "bg-selected text-selected-foreground"
- : "bg-background text-muted-foreground hover:text-foreground hover:bg-muted"
- )}
+ value={i}
+ className="px-4 py-2 text-sm font-bold transition-colors cursor-pointer data-active:bg-selected data-active:text-selected-foreground not-data-active:bg-background not-data-active:text-muted-foreground not-data-active:hover:text-foreground not-data-active:hover:bg-muted"
>
{label}
-
+
))}
-
-
+
+
{tabs[active]}
-
-
+
+
);
}
diff --git a/apps/web/components/docs/search-modal.tsx b/apps/web/components/docs/search-modal.tsx
index 83eb0ac2d..4350e095c 100644
--- a/apps/web/components/docs/search-modal.tsx
+++ b/apps/web/components/docs/search-modal.tsx
@@ -2,7 +2,13 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
-import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPopup,
+ DialogPortal,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { isDocsSearchUrl, normalizePagefindUrl } from "@/lib/docs-search";
@@ -137,14 +143,14 @@ export function SearchModal() {
}
return (
-
-
-
-
+
+
+
- Search documentation
+ Search documentation
{/* Input */}
@@ -228,8 +234,8 @@ export function SearchModal() {
close
-
-
-
+
+
+
);
}
diff --git a/apps/web/components/docs/sidebar.tsx b/apps/web/components/docs/sidebar.tsx
index c4a514135..7472964cc 100644
--- a/apps/web/components/docs/sidebar.tsx
+++ b/apps/web/components/docs/sidebar.tsx
@@ -1,10 +1,18 @@
"use client";
-import { useState, useEffect, useCallback, useSyncExternalStore } from "react";
+import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
import { SiteLink } from "@/components/nav/site-link";
import { useSitePathname } from "@/lib/site-pathname";
import { cn } from "@/lib/utils";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible";
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogClose,
+ DialogPopup,
+ DialogPortal,
+ DialogTrigger,
+} from "@/components/ui/dialog";
import { BookOpen, X, ChevronDown, Search } from "lucide-react";
import { NAV_SECTIONS, type NavSection } from "@/lib/docs-nav";
import { SITE_HEADER_HEIGHT, siteHeaderTop } from "@/lib/announcements";
@@ -138,13 +146,17 @@ export function DocsSidebar() {
const [mobileOpen, setMobileOpen] = useState(false);
const s = useSidebarStyles();
+ // The contents are a phone's: once the page is wide enough for the sidebar
+ // (a rotated tablet, a widened window), close them so the dialog lets go.
useEffect(() => {
if (!mobileOpen) return;
- const handler = (e: KeyboardEvent) => {
- if (e.key === "Escape") setMobileOpen(false);
+ const wide = window.matchMedia("(min-width: 768px)");
+ const closeIfWide = () => {
+ if (wide.matches) setMobileOpen(false);
};
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
+ closeIfWide();
+ wide.addEventListener("change", closeIfWide);
+ return () => wide.removeEventListener("change", closeIfWide);
}, [mobileOpen]);
const handleSearchClick = useCallback(() => {
@@ -154,42 +166,39 @@ export function DocsSidebar() {
return (
<>
- {/* Mobile toggle */}
- setMobileOpen(!mobileOpen)}
- className="md:hidden fixed left-4 z-40 flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-bold rounded-md border bg-background shadow-surface cursor-pointer"
- style={{ top: s.toggle }}
- aria-expanded={mobileOpen}
- aria-label={mobileOpen ? "Close docs contents" : "Open docs contents"}
- >
- {/* A word, not a second hamburger under the site menu's. */}
- {mobileOpen ? : }
- Contents
-
-
- {/* Mobile overlay */}
- {mobileOpen && (
- setMobileOpen(false)}
- />
- )}
-
- {/* Mobile sidebar */}
-
- setMobileOpen(false)} onSearchClick={handleSearchClick} />
-
+ {/* Phone: the contents slide in as a dialog under the header. */}
+
+
+ {/* A word, not a second hamburger under the site menu's. */}
+ {mobileOpen ? : }
+ Contents
+
+
+
+
+ setMobileOpen(false)}
+ onSearchClick={handleSearchClick}
+ />
+ {/* The Contents button sits outside the dialog, out of reach of a
+ touch screen reader inside it; this one is seen only when focused. */}
+
+ Close docs contents
+
+
+
+
{/* Desktop sidebar */}
(null);
- // Escape closes the phone menu and hands focus back to its button.
- useEffect(() => {
- if (!open) return;
- const onKey = (event: KeyboardEvent) => {
- if (event.key !== "Escape") return;
- setOpen(false);
- toggleRef.current?.focus();
- };
- document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [open]);
+ // The phone menu hangs under the header and stays in its stacking context.
+ const headerRef = useRef(null);
// Close the phone menu on navigation, including browser back.
useEffect(() => setOpen(false), [pathname]);
@@ -174,220 +165,229 @@ export function SiteHeader({
return (
<>
-
-
+ {/* In compact mode on phones this group dissolves (display: contents), so
+ the context can wrap onto its own full-width row under the wordmark. */}
+
+
+
+ {site.name}
+
+
+
+ {compact && children ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
- {open && (
- // Behind the header's own background (its stacking context is above the
- // page), so it dims and blocks the page but never the menu button.
- setOpen(false)}
- />
- )}
- {open && (
-
- )}
-
+
+
+
+
+
+
{/* The "Skip to content" link (app/layout.tsx) lands here, past the header. */}
>
diff --git a/apps/web/components/ops/inbox.tsx b/apps/web/components/ops/inbox.tsx
index 4e7bd1091..86435f28d 100644
--- a/apps/web/components/ops/inbox.tsx
+++ b/apps/web/components/ops/inbox.tsx
@@ -13,6 +13,13 @@ import type { QueueItem, QueueView } from "@/lib/ops/tickets";
import { cn } from "@/lib/utils";
import { useOps } from "./ops-shell";
import { Card } from "@/components/ui/card";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
const VIEWS: { value: QueueView; label: string }[] = [
{ value: "waiting", label: "Waiting on you" },
@@ -160,16 +167,21 @@ export function OpsInbox({ number }: { number: string | null }) {
return (
-
+ {
+ setQueue(null);
+ setView(next as typeof view);
+ }}
+ aria-label="Tickets to show"
+ // Its bottom line divides the column, so it keeps the hairline rather
+ // than the darker control edge globals.css gives a radio group.
+ className="flex flex-wrap gap-1 border-b border-border! p-2"
+ >
{VIEWS.map((entry) => (
- {
- setQueue(null);
- setView(entry.value);
- }}
- aria-pressed={view === entry.value}
+ value={entry.value}
className={cn(
"rounded-md px-2 py-1 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
view === entry.value
@@ -178,9 +190,9 @@ export function OpsInbox({ number }: { number: string | null }) {
)}
>
{entry.label}
-
+
))}
-
+
{queue === null ? (
@@ -283,6 +295,8 @@ function TicketPane({
const [error, setError] = useState
(null);
const [saved, setSaved] = useState(null);
const [pickerOpen, setPickerOpen] = useState(false);
+ // Whether the saved-replies menu last closed on a pick (focus then goes to the text).
+ const pickedRef = useRef(false);
const [viewing, setViewing] = useState(false);
const textRef = useRef(null);
const userName = account?.name?.trim() || account?.email || "The user";
@@ -420,73 +434,83 @@ function TicketPane({
className={cn("space-y-3 p-3!", mode === "note" && "border-staff-line bg-staff-wash")}
>
-
setMode("message")}
- aria-pressed={mode === "message"}
- className={cn(
- "rounded-md px-2 py-1",
- mode === "message"
- ? "bg-selected font-medium text-selected-foreground"
- : "text-muted-foreground"
- )}
- >
- Reply
-
-
setMode("note")}
- aria-pressed={mode === "note"}
- className={cn(
- "rounded-md px-2 py-1 text-staff",
- mode === "note" && "bg-background font-medium"
- )}
+ setMode(next as typeof mode)}
+ aria-label="Write"
+ className="flex items-center gap-1"
>
- Note
-
-
-
-
setPickerOpen((open) => !open)}
- className="rounded-md px-2 py-1 text-muted-foreground hover:bg-muted"
+
+ Reply
+
+
+ Note
+
+
+
+ {/* Not modal, like the hand-built list it replaced: a click elsewhere still lands. */}
+
+
Saved replies /
-
- {pickerOpen && (
-
- {saved === null ? (
-
Loading...
- ) : saved.length === 0 ? (
-
- None yet.{" "}
-
- Add one
-
- .
-
- ) : (
- saved.map((reply) => (
-
{
- setBody(
- (current) =>
- (current ? `${current}\n\n` : "") + fillReply(reply.body, account)
- );
- setPickerOpen(false);
- textRef.current?.focus();
- }}
- >
- {reply.title}
-
- ))
- )}
-
- )}
-
+
+
{
+ const picked = pickedRef.current;
+ pickedRef.current = false;
+ return picked ? textRef.current : closeType === "keyboard";
+ }}
+ className="max-h-72 w-72 overflow-y-auto shadow-float"
+ >
+ {saved === null ? (
+ Loading...
+ ) : saved.length === 0 ? (
+
+ None yet. {/* A menu item, so the keyboard reaches it inside the menu. */}
+ }
+ className="inline rounded-none p-0 text-xs underline data-highlighted:bg-muted data-highlighted:text-foreground"
+ >
+ Add one
+
+ .
+
+ ) : (
+ saved.map((reply) => (
+ {
+ pickedRef.current = true;
+ setBody(
+ (current) =>
+ (current ? `${current}\n\n` : "") + fillReply(reply.body, account)
+ );
+ }}
+ >
+ {reply.title}
+
+ ))
+ )}
+
+
{mode === "note" ? "Note" : "Reply"}
diff --git a/apps/web/components/ops/refunds-and-replies.tsx b/apps/web/components/ops/refunds-and-replies.tsx
index b4912ae9f..9e9577671 100644
--- a/apps/web/components/ops/refunds-and-replies.tsx
+++ b/apps/web/components/ops/refunds-and-replies.tsx
@@ -11,6 +11,7 @@ import type { HeldRefund } from "@/lib/ops/refunds";
import { cn } from "@/lib/utils";
import { useOps } from "./ops-shell";
import { Card } from "@/components/ui/card";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
const REASONS: Record = {
duplicate_plan: "A second plan subscription",
@@ -69,24 +70,27 @@ export function OpsRefunds() {
Refunds
-
+ setView(next as typeof view)}
+ aria-label="Refunds to show"
+ className="flex gap-1 text-xs"
+ >
{(["held", "recent"] as const).map((value) => (
- setView(value)}
+ value={value}
className={cn(
- "rounded-md px-2 py-1",
+ "rounded-md px-2 py-1 focus-visible:outline-offset-2",
view === value
? "bg-selected font-medium text-selected-foreground"
: "text-muted-foreground hover:bg-muted"
)}
>
{value === "held" ? "Waiting for you" : "Decided"}
-
+
))}
-
+
Approving hands a refund to the refund worker, which refunds exactly the orders listed,
diff --git a/apps/web/components/ops/sharing.tsx b/apps/web/components/ops/sharing.tsx
index 5a7a8f830..87fcb2cc4 100644
--- a/apps/web/components/ops/sharing.tsx
+++ b/apps/web/components/ops/sharing.tsx
@@ -13,6 +13,8 @@ import { cn } from "@/lib/utils";
import { useOps } from "./ops-shell";
import { Card } from "@/components/ui/card";
import { FieldControl } from "@/components/ui/field";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
+import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
/*
* Operations > Sharing: reports of shared previews (from the public form,
@@ -73,33 +75,32 @@ function Notice({ notice }: { notice: { ok: boolean; text: string } | null }) {
export function OpsSharing() {
const [tab, setTab] = useState("reports");
return (
-
+
setTab(next as Tab)}
+ className="max-w-5xl space-y-4 p-6 lg:p-8"
+ >
Sharing
-
+
{TABS.map((t) => (
- setTab(t.key)}
- className={cn(
- "rounded-md px-2 py-1",
- tab === t.key
- ? "bg-selected font-medium text-selected-foreground"
- : "text-muted-foreground hover:bg-muted"
- )}
+ value={t.key}
+ className="rounded-md px-2 py-1 focus-visible:outline-offset-2 data-active:bg-selected data-active:font-medium data-active:text-selected-foreground not-data-active:text-muted-foreground not-data-active:hover:bg-muted"
>
{t.label}
-
+
))}
-
+
- {tab === "reports" && }
- {tab === "live" && }
- {tab === "blocks" && }
- {tab === "reputation" && }
-
+
+ {tab === "reports" && }
+ {tab === "live" && }
+ {tab === "blocks" && }
+ {tab === "reputation" && }
+
+
);
}
@@ -143,24 +144,27 @@ function Reports() {
return (
-
+ setView(next as typeof view)}
+ aria-label="Reports to show"
+ className="flex flex-wrap items-center gap-2 text-xs"
+ >
{(["open", "resolved"] as const).map((v) => (
- setView(v)}
+ value={v}
className={cn(
- "rounded-md px-2 py-1",
+ "rounded-md px-2 py-1 focus-visible:outline-offset-2",
view === v
? "bg-selected font-medium text-selected-foreground"
: "text-muted-foreground hover:bg-muted"
)}
>
{v === "open" ? "Open" : "Resolved"}
-
+
))}
-
+
{!reports ? (
diff --git a/apps/web/components/ops/view-as.tsx b/apps/web/components/ops/view-as.tsx
index 435794474..6332d9e8f 100644
--- a/apps/web/components/ops/view-as.tsx
+++ b/apps/web/components/ops/view-as.tsx
@@ -9,6 +9,7 @@ import { apiFetch, ApiError, shortDateTime } from "@/lib/support/client";
import { cn } from "@/lib/utils";
import { useOps } from "./ops-shell";
import { CodeBox } from "@/components/ui/code";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
interface Session {
id: string;
@@ -193,24 +194,27 @@ export function ViewAs({ sessionId }: { sessionId: string }) {
{endpoints.length === 0 ? (
No endpoints.
) : (
-
+ setEndpointId(next as string)}
+ aria-label="Endpoint to view"
+ className="ph-no-capture flex flex-col gap-0.5"
+ >
{endpoints.map((endpoint) => (
-
- setEndpointId(endpoint.id)}
- className={cn(
- "w-full truncate rounded-md px-3 py-1.5 text-left text-sm",
- endpoint.id === endpointId
- ? "bg-selected font-medium text-selected-foreground"
- : "text-muted-foreground hover:bg-muted"
- )}
- >
- {endpoint.name || endpoint.slug}
-
-
+
+ {endpoint.name || endpoint.slug}
+
))}
-
+
)}
diff --git a/apps/web/components/settings/allowance-meters.tsx b/apps/web/components/settings/allowance-meters.tsx
index 95e6baeb2..362d56920 100644
--- a/apps/web/components/settings/allowance-meters.tsx
+++ b/apps/web/components/settings/allowance-meters.tsx
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { formatBytes } from "@/types/request";
import { longDate } from "./format";
import { settingsLink } from "./panel";
+import { Meter } from "@/components/ui/meter";
/** At or above this share of an allowance, the bar and its count turn to the warning colour. */
const NEAR = 0.8;
@@ -61,11 +62,8 @@ function AllowanceRow({
{when &&
{when}
}
-
@@ -75,7 +73,7 @@ function AllowanceRow({
// A sliver for any use at all, so "1 of 2,000" never reads as none.
style={{ width: `${Math.max(percent, used > 0 ? 1 : 0)}%` }}
/>
-
+
{amount.used}
diff --git a/apps/web/components/settings/quota-meter.tsx b/apps/web/components/settings/quota-meter.tsx
index e2f58a7e9..41049da0e 100644
--- a/apps/web/components/settings/quota-meter.tsx
+++ b/apps/web/components/settings/quota-meter.tsx
@@ -2,6 +2,7 @@
import { cn } from "@/lib/utils";
import { Avatar } from "./avatar";
+import { Meter } from "@/components/ui/meter";
/** Who holds a seat in a team's rail. */
export type SeatSlot =
@@ -102,11 +103,8 @@ export function QuotaMeter({
)}
-
@@ -140,7 +138,7 @@ export function QuotaMeter({
);
})}
-
+
{labelled && seats && (
diff --git a/apps/web/components/support/new-ticket-form.tsx b/apps/web/components/support/new-ticket-form.tsx
index 474c81881..322334ebd 100644
--- a/apps/web/components/support/new-ticket-form.tsx
+++ b/apps/web/components/support/new-ticket-form.tsx
@@ -19,6 +19,7 @@ import { isDocsSearchUrl, normalizePagefindUrl } from "@/lib/docs-search";
import { BODY_MAX, SUBJECT_MAX, SUPPORT_CATEGORIES } from "@/lib/support/shared";
import type { SupportCategory } from "@/lib/supabase/database";
import { cn } from "@/lib/utils";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
import { CheckRow, ImagePicker, supportBody } from "./thread";
import { Card } from "@/components/ui/card";
@@ -220,14 +221,15 @@ export function NewTicketForm() {
}>
What is it about?
-
+ setCategory(next as typeof category)}
+ className="flex flex-wrap gap-2"
+ >
{SUPPORT_CATEGORIES.map((entry) => (
- setCategory(entry.value)}
+ value={entry.value}
className={cn(
"rounded-md border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
category === entry.value
@@ -236,9 +238,9 @@ export function NewTicketForm() {
)}
>
{entry.label}
-
+
))}
-
+
@@ -314,17 +316,18 @@ export function NewTicketForm() {
Is this blocking you right now?
-
+ setBlocking(next as boolean)}
+ className="flex gap-2"
+ >
{[
{ value: true, label: "Yes, it is" },
{ value: false, label: "No" },
].map((option) => (
- setBlocking(option.value)}
+ value={option.value}
className={cn(
"rounded-md border px-3 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
blocking === option.value
@@ -333,9 +336,9 @@ export function NewTicketForm() {
)}
>
{option.label}
-
+
))}
-
+
diff --git a/apps/web/components/support/ticket-view.tsx b/apps/web/components/support/ticket-view.tsx
index e72483535..c6e0549f2 100644
--- a/apps/web/components/support/ticket-view.tsx
+++ b/apps/web/components/support/ticket-view.tsx
@@ -11,6 +11,7 @@ import { apiFetch, ApiError, shortDateTime, shortDay, timeAgo } from "@/lib/supp
import { BODY_MAX, categoryLabel, type SupportTicketDetail } from "@/lib/support/shared";
import { subscribeToSupportTickets } from "@/lib/supabase/realtime";
import { cn } from "@/lib/utils";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
import { CheckRow, Conversation, ImagePicker, supportBody, TurnLine } from "./thread";
import { Card } from "@/components/ui/card";
@@ -54,18 +55,16 @@ function Rating({
return (
How did we do?
-
setRating(next as number)}
className="flex gap-1.5"
- role="radiogroup"
aria-label="Rating from 1 (poor) to 5 (great)"
>
{[1, 2, 3, 4, 5].map((value) => (
- setRating(value)}
+ value={value}
className={cn(
"h-9 w-9 rounded-md border text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
rating === value
@@ -74,9 +73,9 @@ function Rating({
)}
>
{value}
-
+
))}
-
+
1 is poor, 5 is great.
{rating !== null && (
<>
diff --git a/apps/web/components/teams/team-billing-panel.tsx b/apps/web/components/teams/team-billing-panel.tsx
index a534ea17c..d8931cdec 100644
--- a/apps/web/components/teams/team-billing-panel.tsx
+++ b/apps/web/components/teams/team-billing-panel.tsx
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Minus, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { Radio, RadioGroup } from "@/components/ui/radio-group";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
@@ -41,6 +42,11 @@ import {
import { longDate } from "@/components/settings/format";
import { TeamPlanChooser, currentTeamPlan } from "@/components/teams/team-plan-chooser";
+/** A team plan option as one radio value. */
+function planKey(option: { plan: string; interval: string }) {
+ return `${option.plan}-${option.interval}`;
+}
+
const SESSION_EXPIRED = "Your session expired. Please sign in again.";
function formatDate(millis: number | null): string {
@@ -339,26 +345,33 @@ export function TeamBillingPanel({
)}
-
- {TEAM_PLAN_OPTIONS.map((option) => {
- const selected =
- option.plan === checkoutPlan.plan && option.interval === checkoutPlan.interval;
- return (
- setCheckoutPlan(option)}
- >
- {teamPlanLabel(option.plan, option.interval)}
-
- );
- })}
-
+ {
+ const option = TEAM_PLAN_OPTIONS.find((entry) => planKey(entry) === next);
+ if (option) setCheckoutPlan(option);
+ }}
+ disabled={subscribing}
+ aria-label="Plan"
+ className="flex flex-wrap gap-2"
+ >
+ {TEAM_PLAN_OPTIONS.map((option) => (
+
+ }
+ >
+ {teamPlanLabel(option.plan, option.interval)}
+
+ ))}
+
`; give it
+ * `render` to make an existing element the area. To act on the row that was
+ * pressed, read `details.event.target` in `onOpenChange` (a long press on iOS
+ * fires no `contextmenu` event, so a row's own handler would miss it).
+ *
+ * Modal, as the browser's own context menu is: a press outside only closes it
+ * (Base UI ignores `modal` on a context menu).
+ */
+const ContextMenu = ContextMenuPrimitive.Root;
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+
+/** The menu panel, its top left corner at the pointer. Drawn as a `list` menu by default. */
+function ContextMenuContent({
+ className,
+ look = "list",
+ ...props
+}: Styled & { look?: MenuLook }) {
+ return (
+
+
+
+
+
+ );
+}
+
+function ContextMenuItem({
+ className,
+ look = "list",
+ ...props
+}: Styled & { look?: MenuLook }) {
+ return ;
+}
+
+export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem };
diff --git a/apps/web/components/ui/dialog.tsx b/apps/web/components/ui/dialog.tsx
index 4262caa4e..a0caccc2d 100644
--- a/apps/web/components/ui/dialog.tsx
+++ b/apps/web/components/ui/dialog.tsx
@@ -10,6 +10,15 @@ const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
+/**
+ * The unstyled parts, for a dialog drawn its own way (a drawer, a search
+ * palette): wrap the Popup and Backdrop in a Portal.
+ */
+const DialogPortal = DialogPrimitive.Portal;
+const DialogBackdrop = DialogPrimitive.Backdrop;
+const DialogPopup = DialogPrimitive.Popup;
+const DialogClose = DialogPrimitive.Close;
+
/** The panel, centred over a dimmed page, with a close button in its corner. */
function DialogContent({ className, children, ...props }: Styled) {
return (
@@ -71,6 +80,10 @@ function DialogDescription({ className, ...props }: Styled = {
+ panel:
+ "min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
+ list: "min-w-[160px] overflow-hidden rounded-lg border bg-background shadow-float outline-none",
+};
+
+const ITEM_LOOK: Record = {
+ panel:
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ list: "flex w-full cursor-pointer select-none items-center gap-2 px-3 py-2 text-left text-xs font-bold outline-none transition-colors data-highlighted:bg-muted data-disabled:pointer-events-none data-disabled:opacity-50",
+};
+
+/** The classes of a menu panel in the given look, for the context menu too. */
+export function menuContentClass(look: MenuLook, className?: string) {
+ return cn(CONTENT_LOOK[look], className);
+}
+
+/** The classes of a menu item in the given look. */
+export function menuItemClass(look: MenuLook, className?: string) {
+ return cn(ITEM_LOOK[look], className);
+}
+
type DropdownMenuContentProps = Styled &
- Pick;
+ Pick & {
+ look?: MenuLook;
+ };
/** The menu panel, placed against its trigger (below it, 4px away, by default). */
function DropdownMenuContent({
@@ -21,6 +52,7 @@ function DropdownMenuContent({
side,
sideOffset = 4,
collisionAvoidance,
+ look = "panel",
...props
}: DropdownMenuContentProps) {
return (
@@ -32,28 +64,18 @@ function DropdownMenuContent({
sideOffset={sideOffset}
collisionAvoidance={collisionAvoidance}
>
-
+
);
}
-function DropdownMenuItem({ className, ...props }: Styled) {
- return (
-
- );
+function DropdownMenuItem({
+ className,
+ look = "panel",
+ ...props
+}: Styled & { look?: MenuLook }) {
+ return ;
}
/** A heading inside the menu. Not tied to a group, so it can head any run of items. */
diff --git a/apps/web/components/ui/meter.tsx b/apps/web/components/ui/meter.tsx
new file mode 100644
index 000000000..4689db5de
--- /dev/null
+++ b/apps/web/components/ui/meter.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { Meter as MeterPrimitive } from "@base-ui/react/meter";
+
+/**
+ * How much of a known amount is used, such as a quota (`role="meter"`).
+ * Unstyled: the call site draws the bar inside it. `value` is a percentage
+ * unless `max` says otherwise; `aria-label` names it.
+ */
+const Meter = MeterPrimitive.Root;
+
+export { Meter };
diff --git a/apps/web/components/ui/popover.tsx b/apps/web/components/ui/popover.tsx
new file mode 100644
index 000000000..a8a139071
--- /dev/null
+++ b/apps/web/components/ui/popover.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
+
+/**
+ * A panel a button opens beside something on the page; it closes on Escape,
+ * on a press outside it and when focus leaves it, and hands focus back to its
+ * button. Unstyled parts: Root, Trigger, Portal (its `container` keeps the
+ * panel in another element's stacking context), Backdrop, Positioner
+ * (`anchor` places it against an element other than the trigger) and Popup.
+ */
+const Popover = PopoverPrimitive;
+
+export { Popover };
diff --git a/apps/web/components/ui/radio-group.tsx b/apps/web/components/ui/radio-group.tsx
new file mode 100644
index 000000000..2fdce039a
--- /dev/null
+++ b/apps/web/components/ui/radio-group.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Radio as RadioPrimitive } from "@base-ui/react/radio";
+import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group";
+
+/**
+ * A set of choices of which one is picked: one tab stop for the group, and
+ * the arrow keys move the choice. Unstyled; a change is reported through
+ * `onValueChange` only (a radio's `onClick` still runs, but arrow keys never
+ * click, and a click on the picked radio reports nothing). Each radio leaves a hidden
+ * input beside it, out of the flow, so style the group with `gap-*` rather
+ * than `space-*`, `divide-*` or `first:`/`last:`.
+ */
+const RadioGroup = RadioGroupPrimitive;
+
+/**
+ * One choice in a RadioGroup, a native button unless `render` gives another
+ * element (pass `nativeButton` too when that element is a button). It carries
+ * `data-checked` when picked.
+ */
+function Radio({ render, nativeButton, ...props }: RadioPrimitive.Root.Props) {
+ return (
+ }
+ {...props}
+ />
+ );
+}
+
+export { RadioGroup, Radio };
diff --git a/apps/web/components/ui/switch.tsx b/apps/web/components/ui/switch.tsx
new file mode 100644
index 000000000..b307d8274
--- /dev/null
+++ b/apps/web/components/ui/switch.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+
+/**
+ * An on/off control (`role="switch"`). Unstyled parts: Root (listen with
+ * `onCheckedChange`; with `nativeButton` and `render={ }` its `id`
+ * lands on the button, so a sibling `` names it) and Thumb.
+ * The settings' drawn switch is `Switch` in components/dashboard/settings-primitives.
+ */
+const SwitchRoot = SwitchPrimitive.Root;
+const SwitchThumb = SwitchPrimitive.Thumb;
+
+export { SwitchRoot, SwitchThumb };
diff --git a/apps/web/lib/retired-classes.test.ts b/apps/web/lib/retired-classes.test.ts
index 6498f4aab..c1363b61e 100644
--- a/apps/web/lib/retired-classes.test.ts
+++ b/apps/web/lib/retired-classes.test.ts
@@ -96,6 +96,32 @@ describe("retired style classes", () => {
expect(violations).toEqual([]);
});
+ // Menus, dialogs, tabs, radio groups, switches and meters are Base UI parts
+ // (components/ui), which bring their keyboard, focus and dismiss rules; a
+ // widget role written by hand outside components/ui means one was rebuilt.
+ // The cookie banner is the one exception: a non-modal notice that must be
+ // answered, with no keyboard or dismiss behaviour of its own to replace.
+ it("builds its widgets on the Base UI parts", () => {
+ // role="menu", role={"menu"}, role={open ? "menu" : undefined} and a props
+ // object's role: "menu" alike.
+ const WIDGET =
+ /\brole(?:=|=\{[^}]*?|\s*:\s*)["'`](?:radio|radiogroup|switch|checkbox|progressbar|meter|slider|menu|menuitem(?:radio|checkbox)?|menubar|tab|tablist|tabpanel|dialog|alertdialog|tooltip|combobox)["'`]|aria-modal/;
+ const ALLOWED = new Set([path.join("components", "providers", "cookie-consent.tsx")]);
+ const violations = files
+ .filter((file) => !file.includes(`${path.sep}components${path.sep}ui${path.sep}`))
+ .filter((file) => !ALLOWED.has(path.relative(ROOT, file)))
+ .flatMap((file) =>
+ readFileSync(file, "utf8")
+ .split("\n")
+ .map((line, index) => ({ line, where: `${path.relative(ROOT, file)}:${index + 1}` }))
+ .filter(({ line }) => !COMMENT.test(line) && WIDGET.test(line))
+ .map(
+ ({ line, where }) => `${where} has ${line.match(WIDGET)![0]}; use a components/ui part`
+ )
+ );
+ expect(violations).toEqual([]);
+ });
+
it("keeps globals.css free of the retired tokens", () => {
const css = readFileSync(path.join(ROOT, "app", "globals.css"), "utf8");
for (const token of [
diff --git a/apps/web/tests/e2e/email-fields.spec.ts b/apps/web/tests/e2e/email-fields.spec.ts
index b5e6f8157..967fcd0b7 100644
--- a/apps/web/tests/e2e/email-fields.spec.ts
+++ b/apps/web/tests/e2e/email-fields.spec.ts
@@ -164,7 +164,7 @@ test("the email shows the field and the reply on its own", async ({ page }) => {
await expect(shown(page.getByRole("button", { name: "Copy invoice" }))).toBeVisible();
await shown(page.getByRole("tab", { name: "Text", exact: true })).click();
- await shown(page.getByRole("button", { name: "Reply only" })).click();
+ await shown(page.getByRole("radio", { name: "Reply only" })).click();
await expect(shown(page.getByText("Paid this morning, thanks!", { exact: true }))).toBeVisible();
await expect(shown(page.getByText("> Total: 42.00 EUR"))).toHaveCount(0);
});
diff --git a/apps/web/tests/e2e/settings.spec.ts b/apps/web/tests/e2e/settings.spec.ts
index 3322c6924..9dfabc4ae 100644
--- a/apps/web/tests/e2e/settings.spec.ts
+++ b/apps/web/tests/e2e/settings.spec.ts
@@ -168,9 +168,7 @@ test("an API key is created once, listed, and deleted", async ({ page }) => {
test("the seat rail shows pooled usage and who holds each seat", async ({ page }) => {
await openTeam(page, owner);
- await expect(
- page.getByRole("progressbar", { name: "120,000 of 300,000 requests used" })
- ).toBeVisible();
+ await expect(page.getByRole("meter", { name: "120,000 of 300,000 requests used" })).toBeVisible();
const seats = page.getByRole("list", { name: "Seats" });
await expect(seats.getByRole("listitem")).toHaveCount(3);
await expect(seats.getByText("You")).toBeVisible();
diff --git a/apps/web/tests/e2e/stored-xss.spec.ts b/apps/web/tests/e2e/stored-xss.spec.ts
index 15a4abdfc..6ee4a55ec 100644
--- a/apps/web/tests/e2e/stored-xss.spec.ts
+++ b/apps/web/tests/e2e/stored-xss.spec.ts
@@ -260,7 +260,7 @@ test("schema view and share page keep hostile JSON keys inert", async ({ page })
await signInTestUser(page, testUser, `/dashboard?endpoint=${endpointSlug}`);
await page.getByText(JSON_PATH).first().click();
await page.getByRole("tab", { name: /^body$/i }).click();
- await page.getByRole("button", { name: "Schema", exact: true }).click();
+ await page.getByRole("radio", { name: "Schema", exact: true }).click();
for (const format of ["TypeScript", "Zod", "Pydantic", "JSON Schema"]) {
await test.step(`${format} schema stays inert`, async () => {
@@ -281,9 +281,9 @@ test("schema view and share page keep hostile JSON keys inert", async ({ page })
await test.step("the share page keeps the payload inert", async () => {
await page.goto(`/s/${token}`);
await expect(page.getByText("Shared request")).toBeVisible();
- await page.getByRole("button", { name: "Formatted", exact: true }).click();
+ await page.getByRole("radio", { name: "Formatted", exact: true }).click();
await expectInertCode(page, dialogs);
- await page.getByRole("button", { name: "Schema", exact: true }).click();
+ await page.getByRole("radio", { name: "Schema", exact: true }).click();
await expectInertCode(page, dialogs);
});
});