diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 51d3c6c9f3..43e632d96c 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -44,8 +44,9 @@ if (
/**
* Applies a captured VS Code theme dump (`pnpm sync:vscode-themes`) as one
- * `:root` stylesheet and mirrors VS Code's body attribute for theme-aware
- * hooks. Synchronous and idempotent, so stories render fully themed.
+ * `:root` stylesheet and mirrors VS Code's body theme attributes for
+ * theme-aware hooks. Synchronous and idempotent, so stories render fully
+ * themed.
*/
let appliedTheme: string | undefined;
@@ -68,6 +69,7 @@ function applyTheme(requested: string): void {
.map(([property, value]) => `${property}: ${value};`)
.join("")}}`;
document.body.setAttribute("data-vscode-theme-kind", `vscode-${slug}`);
+ document.body.setAttribute("data-vscode-theme-id", slug);
}
/* Pixel's autofit crop follows in-flow layout, but portalled overlays
diff --git a/package.json b/package.json
index a2b0b42332..b1387e742d 100644
--- a/package.json
+++ b/package.json
@@ -843,7 +843,7 @@
"prettier": "^3.9.6",
"react": "catalog:",
"react-dom": "catalog:",
- "storybook": "^10.5.5",
+ "storybook": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.65.0",
"utf-8-validate": "^6.0.6",
diff --git a/packages/ui/README.md b/packages/ui/README.md
index bc548386f4..ddf231ac8b 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -19,6 +19,9 @@ import "@repo/ui/codicon.css";
`tokens.css` is the only layer that references VS Code's injected
`--vscode-*` variables. Components reference `--ui-*` tokens only.
+`--ui-background` is the sidebar surface, the common webview host; a webview
+hosted in an editor tab or bottom panel uses `--ui-panel-background` instead,
+since VS Code gives webviews no host signal to resolve it automatically.
Component CSS is inherit-first: typography and text color come from the
webview (`font: inherit`), and controls center content with a fixed height
@@ -35,9 +38,19 @@ after the library overrides any default (width, height, spacing).
`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives,
styled to match the native VS Code menu and hover widgets. Menus expose
Radix's compound parts as flat named exports (`DropdownMenuTrigger`,
-`DropdownMenuItem`, …); `Tooltip` is a single component taking a `content`
-prop, with a 500ms show delay matching VS Code's `workbench.hover.delay`
-default.
+`DropdownMenuItem`, `DropdownMenuCheckboxItem`, …): checkbox and radio
+items show a check in the icon gutter, `*Label` renders a group heading, and
+`*Keybinding` renders a shortcut hint. Pass `keys` the same `key`/`mac`/
+`win`/`linux` fields as a keybindings contribution to get the current OS's
+binding in its native label style (`⇧⌘R` on macOS, `Ctrl+Shift+R`
+elsewhere); `formatKeybinding` does the same for other surfaces, such as
+tooltips.
+
+`Tooltip` is a single component taking a `content` prop, and requires a
+`TooltipProvider` ancestor. Mount one provider per app so that a pointer
+moving between nearby triggers skips the show delay, like native hovers.
+The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
+and tooltips stop growing at half the window height.
Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
@@ -49,39 +62,13 @@ an interrupted entry animation cannot delay unmounting. High contrast,
Deliberate deferrals, fine to fix later.
-Overlays:
-
-- Menus only support plain action items; Radix's checkbox/radio items,
- group labels, and keybinding hints have no styled wrappers yet.
-- Moving the pointer from one tooltip trigger straight to another replays
- the full 500ms delay, where native shows the next hover instantly. The
- fix is one shared `TooltipProvider` per app instead of one per
- `Tooltip`.
- Overlay shadows are darker than native in dark themes: menus in VS Code
use `shadow-lg`, which webviews cannot read, so the closest available
`widget.shadow` stands in.
-- A very tall tooltip fills most of the viewport before it scrolls, where
- native hovers stop at half the window height.
-
-Package-wide:
-
-- There is no `Button`; the VS Code button style exists only inside the
- state panels, and secondary-button colors have no `--ui-*` tokens.
-- Only the Empty and Error panels ship; a Loading panel would need the
- shared panel skeleton, which stays internal.
-- The token layer maps what shipped components need: there are no
- list/selection-row, spacing, typography, or z-index tokens, and the
- `--ui-radius-*` tokens are only adopted by the overlays, with older
- controls hardcoding their radii.
-- `--ui-background` assumes a sidebar webview; a webview hosted in an
- editor tab or panel renders on the sidebar color.
-- `useVscodeTheme` reports the theme kind only; switching between two
- themes of the same kind does not notify subscribers.
-- Under `prefers-reduced-motion` the indeterminate `ProgressBar` renders
- as a full bar and the `Spinner` as a static ring, with no other
- activity cue.
-- Story helpers compile against root-hoisted Storybook packages; a
- standalone split needs its own Storybook devDependencies.
+- Keybinding hints show the contributed defaults the consumer passes, not
+ user remaps: VS Code exposes no API for extensions to resolve a command's
+ effective keybinding.
+- List/selection-row tokens are deferred to the Tree suite (#1037).
## Codicons
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8f64bb0e86..940281fe90 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -35,10 +35,12 @@
"react-dom": "catalog:"
},
"devDependencies": {
+ "@storybook/react-vite": "catalog:",
"@types/react": "catalog:",
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+ "storybook": "catalog:",
"typescript": "catalog:"
}
}
diff --git a/packages/ui/src/components/Button/Button.css b/packages/ui/src/components/Button/Button.css
new file mode 100644
index 0000000000..bf0282a042
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.css
@@ -0,0 +1,35 @@
+/* Matches monaco-text-button: 26px tall with 8px horizontal padding. */
+.ui-button {
+ height: 26px;
+ padding: 0 8px;
+ color: var(--ui-button-foreground);
+ background: var(--ui-button-background);
+ border: 1px solid var(--ui-button-border);
+ border-radius: var(--ui-radius-small);
+ cursor: pointer;
+}
+
+.ui-button:hover:not(:disabled) {
+ background: var(--ui-button-hover-background);
+}
+
+/* Native buttons outline on :focus, so a click shows it too */
+.ui-button:focus {
+ outline: 1px solid var(--ui-focus-border);
+ outline-offset: 2px;
+}
+
+.ui-button:disabled {
+ opacity: var(--ui-disabled-opacity);
+ cursor: default;
+}
+
+.ui-button--secondary {
+ color: var(--ui-button-secondary-foreground);
+ background: var(--ui-button-secondary-background);
+ border-color: var(--ui-button-secondary-border);
+}
+
+.ui-button--secondary:hover:not(:disabled) {
+ background: var(--ui-button-secondary-hover-background);
+}
diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx
new file mode 100644
index 0000000000..a578b5952a
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.stories.tsx
@@ -0,0 +1,31 @@
+import { expect, within } from "storybook/test";
+
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Button } from "./Button";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const ButtonStates = (): React.JSX.Element => (
+
+
+
+
+
+);
+
+const meta: Meta = {
+ title: "UI/Button",
+ component: ButtonStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {
+ play: async ({ canvasElement }) => {
+ await expect(
+ within(canvasElement).getByRole("button", { name: "Rebuild" }),
+ ).toBeDisabled();
+ },
+};
diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx
new file mode 100644
index 0000000000..a6a735c929
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.tsx
@@ -0,0 +1,32 @@
+import { type ComponentProps } from "react";
+
+import { cx } from "#cx";
+
+import "../control.css";
+
+import "./Button.css";
+
+export interface ButtonProps extends ComponentProps<"button"> {
+ variant?: "primary" | "secondary";
+}
+
+/** Text button matching VS Code's monaco-text-button. */
+export function Button({
+ variant = "primary",
+ className,
+ type = "button",
+ ...props
+}: ButtonProps): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx
index 3a6ea7c058..bb02fffc80 100644
--- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx
+++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx
@@ -8,6 +8,8 @@ import "../overlay.css";
import type { ComponentPropsWithRef } from "react";
+export { MenuKeybinding as ContextMenuKeybinding } from "../menu";
+
/** Root state container; wraps the trigger and content. */
export const ContextMenu = ContextMenuPrimitive.Root;
@@ -70,6 +72,66 @@ export function ContextMenuItem({
);
}
+/** A toggleable row; shows the native-style check in the gutter when checked. */
+export function ContextMenuCheckboxItem({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef<
+ typeof ContextMenuPrimitive.CheckboxItem
+>): React.JSX.Element {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+/** Groups `ContextMenuRadioItem`s into one exclusive selection. */
+export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+/** One choice in a radio group; native menus check the active choice. */
+export function ContextMenuRadioItem({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef<
+ typeof ContextMenuPrimitive.RadioItem
+>): React.JSX.Element {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+/** Non-interactive heading above a group of items. */
+export function ContextMenuLabel({
+ className,
+ ...props
+}: ComponentPropsWithRef<
+ typeof ContextMenuPrimitive.Label
+>): React.JSX.Element {
+ return (
+
+ );
+}
+
/** The row that opens its submenu; renders a trailing chevron. */
export function ContextMenuSubTrigger({
className,
diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
index 2eef395388..6368f27c13 100644
--- a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
+++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
@@ -1,17 +1,19 @@
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
-import {
- openSubmenuByKeyboard,
- PIXEL_ALL_THEMES,
- STORY_TRIGGER_CLASS,
-} from "#storybook";
+import { openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook";
+import { Button } from "../Button/Button";
import { Icon } from "../Icon/Icon";
import {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuKeybinding,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -24,9 +26,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
const MenuExample = (): React.JSX.Element => (
-
+
@@ -37,6 +37,22 @@ const MenuExample = (): React.JSX.Element => (
Stop
+
+ Rebuild
+
+
+
+
+ Start on connect
+
+
+ Sort by
+
+ Name
+ Status
+ More actions
@@ -63,6 +79,12 @@ export const Open: Story = {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Workspace actions" }),
);
+ await expect(
+ await screen.findByRole("menuitemcheckbox", { name: "Start on connect" }),
+ ).toHaveAttribute("aria-checked", "true");
+ await expect(
+ screen.getByRole("menuitemradio", { name: "Name" }),
+ ).toHaveAttribute("aria-checked", "true");
await openSubmenuByKeyboard("Open logs");
},
};
@@ -72,9 +94,7 @@ export const ManyItems: Story = {
render: () => (
-
+
{Array.from({ length: 40 }, (_, i) => (
diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
index b02daab587..0f6e1b8101 100644
--- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
+++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
@@ -8,6 +8,8 @@ import "../overlay.css";
import type { ComponentPropsWithRef } from "react";
+export { MenuKeybinding as DropdownMenuKeybinding } from "../menu";
+
/** Root state container; wraps the trigger and content. */
export const DropdownMenu = DropdownMenuPrimitive.Root;
@@ -74,6 +76,66 @@ export function DropdownMenuItem({
);
}
+/** A toggleable row; shows the native-style check in the gutter when checked. */
+export function DropdownMenuCheckboxItem({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef<
+ typeof DropdownMenuPrimitive.CheckboxItem
+>): React.JSX.Element {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+/** Groups `DropdownMenuRadioItem`s into one exclusive selection. */
+export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+/** One choice in a radio group; native menus check the active choice. */
+export function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: ComponentPropsWithRef<
+ typeof DropdownMenuPrimitive.RadioItem
+>): React.JSX.Element {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+/** Non-interactive heading above a group of items. */
+export function DropdownMenuLabel({
+ className,
+ ...props
+}: ComponentPropsWithRef<
+ typeof DropdownMenuPrimitive.Label
+>): React.JSX.Element {
+ return (
+
+ );
+}
+
/** The row that opens its submenu; renders a trailing chevron. */
export function DropdownMenuSubTrigger({
className,
diff --git a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
index d2115447c1..5ba265869a 100644
--- a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
+++ b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
@@ -1,5 +1,7 @@
import { PIXEL_ALL_THEMES } from "#storybook";
+import { Button } from "../Button/Button";
+
import { EmptyState } from "./EmptyState";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -9,7 +11,7 @@ const EmptyStates = (): React.JSX.Element => (
Create workspace}
+ action={}
/>
- {retryLabel}
-
- ) : undefined)
+ (onRetry ? : undefined)
}
/>
);
diff --git a/packages/ui/src/components/IconButton/IconButton.css b/packages/ui/src/components/IconButton/IconButton.css
index 721abb4635..0efa9dfc02 100644
--- a/packages/ui/src/components/IconButton/IconButton.css
+++ b/packages/ui/src/components/IconButton/IconButton.css
@@ -5,23 +5,25 @@
color: var(--ui-toolbar-foreground);
background: transparent;
border: 0;
- border-radius: 5px;
+ border-radius: var(--ui-radius-medium);
cursor: pointer;
outline: 1px solid transparent;
outline-offset: -1px;
}
-.ui-icon-button:hover:not(:disabled) {
+/* A toggled button keeps its active colors on hover, like native */
+.ui-icon-button:hover:not(:disabled, [aria-pressed="true"]) {
background: var(--ui-toolbar-hover-background);
outline-color: var(--ui-toolbar-hover-outline);
outline-style: dashed;
}
-.ui-icon-button:active:not(:disabled) {
+.ui-icon-button:active:not(:disabled, [aria-pressed="true"]) {
background: var(--ui-toolbar-active-background);
}
-.ui-icon-button:focus-visible {
+/* Native buttons outline on :focus, so a click shows it too */
+.ui-icon-button:focus {
outline-color: var(--ui-focus-border);
outline-style: solid;
}
diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx
index e700e1c166..e5d308661a 100644
--- a/packages/ui/src/components/IconButton/IconButton.tsx
+++ b/packages/ui/src/components/IconButton/IconButton.tsx
@@ -17,11 +17,12 @@ export interface IconButtonProps extends Omit<
label: string;
}
+/* No default title: native toolbar buttons hint with the styled hover
+ widget, not the browser box. Wrap in Tooltip for that. */
export function IconButton({
icon,
label,
className,
- title = label,
type = "button",
...props
}: IconButtonProps): React.JSX.Element {
@@ -29,7 +30,6 @@ export function IconButton({