Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .storybook/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ html body {
overflow: auto;
}

/* Shrink the root from a full-width block to its content so Pixel's
autofit crop stays tight. */
/* Shrink-wrap the content so Pixel's autofit crop stays tight. */
#storybook-root {
width: fit-content;
}
Expand Down
29 changes: 11 additions & 18 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -68,13 +69,14 @@ 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
(menus, tooltips) are out of flow and would be cropped away. Grow the
story root to cover any element portalled to body. Relies on the
padded (top-left anchored) layout: growth only extends right and
down, so already-positioned overlays never move. */
story root to cover them. Relies on the padded (top-left anchored)
layout: growth only extends right and down, so already-positioned
overlays never move. */
function fitRootToPortals(): void {
const root = document.getElementById("root");
if (!root) {
Expand All @@ -83,19 +85,10 @@ function fitRootToPortals(): void {
const origin = root.getBoundingClientRect();
let right = 0;
let bottom = 0;
for (const el of document.body.children) {
// Skip Storybook chrome (root, loaders, error display, a11y helpers)
if (
!(el instanceof HTMLElement) ||
el.id.startsWith("storybook-") ||
el.classList.contains("sb-wrapper")
) {
continue;
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
continue;
}
for (const overlay of document.querySelectorAll(
"[data-radix-popper-content-wrapper]",
)) {
const rect = overlay.getBoundingClientRect();
right = Math.max(right, rect.right - origin.left);
bottom = Math.max(bottom, rect.bottom - origin.top);
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 25 additions & 35 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ 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.

The radius and spacing tokens mirror VS Code's scale (`baseSizes.ts`), but
only the rungs components actually use are declared; add one when a component
needs it. Native menu, button, and hover paddings are hardcoded rather than
scale-derived, so parity-pinned values stay literals.

Component CSS is inherit-first: typography and text color come from the
webview (`font: inherit`), and controls center content with a fixed height
Expand All @@ -35,9 +43,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,
Expand All @@ -47,41 +65,13 @@ an interrupted entry animation cannot delay unmounting. High contrast,

## Known gaps

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

Expand Down
4 changes: 3 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"imports": {
"#cx": "./src/cx.ts",
"#codicons": "./src/codicons.ts",
"#storybook": "./src/storybook.tsx"
"#storybook": "./src/storybook.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
Expand All @@ -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:"
}
}
30 changes: 30 additions & 0 deletions packages/ui/src/components/Button/Button.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* monaco-text-button, flattened to a flex row: its 4px padding, 16px
leading, and 1px border make the 26px height. */
.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);
}

.ui-button:focus {
outline: 1px solid var(--ui-focus-border);
outline-offset: 2px;
}

.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);
}
23 changes: 23 additions & 0 deletions packages/ui/src/components/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { PIXEL_ALL_THEMES } from "#storybook";

import { Button } from "./Button";

import type { Meta, StoryObj } from "@storybook/react-vite";

const ButtonStates = (): React.JSX.Element => (
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Button>Start workspace</Button>
<Button variant="secondary">Open logs</Button>
<Button disabled>Rebuild</Button>
</div>
);

const meta: Meta<typeof ButtonStates> = {
title: "UI/Button",
component: ButtonStates,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof ButtonStates>;

export const States: Story = {};
32 changes: 32 additions & 0 deletions packages/ui/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
{...props}
type={type}
className={cx(
"ui-control",
"ui-button",
variant === "secondary" && "ui-button--secondary",
className,
)}
/>
);
}
21 changes: 21 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import { Icon } from "../Icon/Icon";

import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuKeybinding,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
Expand Down Expand Up @@ -39,6 +44,22 @@ const MenuExample = (): React.JSX.Element => (
<Icon name="stop-circle" />
Stop
</ContextMenuItem>
<ContextMenuItem>
Rebuild
<ContextMenuKeybinding
keys={{ key: "ctrl+shift+r", mac: "cmd+shift+r" }}
/>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem checked>
Start on connect
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>Sort by</ContextMenuLabel>
<ContextMenuRadioGroup value="name">
<ContextMenuRadioItem value="name">Name</ContextMenuRadioItem>
<ContextMenuRadioItem value="status">Status</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
Expand Down
Loading