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
4 changes: 4 additions & 0 deletions .storybook/playground-themes.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// The playground publishes untyped ESM theme modules (JSDoc only).
declare module "@vscode-elements/webview-playground/dist/themes/*.js" {
export const theme: Array<[string, string]>;
}
43 changes: 36 additions & 7 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/// <reference types="vite/client" />

import codiconCssUrl from "@vscode/codicons/dist/codicon.css?url";
import { theme as darkTheme } from "@vscode-elements/webview-playground/dist/themes/dark-v2.js";
import { theme as hcDarkTheme } from "@vscode-elements/webview-playground/dist/themes/hc-dark.js";
import { theme as hcLightTheme } from "@vscode-elements/webview-playground/dist/themes/hc-light.js";
import { theme as lightTheme } from "@vscode-elements/webview-playground/dist/themes/light-v2.js";
import { createElement, useEffect } from "react";

import "./global.css";
import { darkTheme } from "./themes/dark-v2";
import { lightTheme } from "./themes/light-v2";

import type { Preview } from "@storybook/react-vite";
import type { WebviewApi } from "vscode-webview";
Expand Down Expand Up @@ -49,6 +51,19 @@ if (
const VSCODE_FONT_FAMILY =
'"Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif';

const THEMES: Record<
string,
{ variables: Array<[string, string]>; kind: string }
> = {
light: { variables: lightTheme, kind: "vscode-light" },
dark: { variables: darkTheme, kind: "vscode-dark" },
"high-contrast": { variables: hcDarkTheme, kind: "vscode-high-contrast" },
"high-contrast-light": {
variables: hcLightTheme,
kind: "vscode-high-contrast-light",
},
};

const preview: Preview = {
parameters: {
layout: "centered",
Expand All @@ -63,33 +78,47 @@ const preview: Preview = {
items: [
{ value: "light", icon: "circlehollow", title: "Light" },
{ value: "dark", icon: "circle", title: "Dark" },
{
value: "high-contrast",
icon: "contrast",
title: "High Contrast",
},
{
value: "high-contrast-light",
icon: "sun",
title: "High Contrast Light",
},
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const selectedTheme =
context.globals.theme === "light" ? lightTheme : darkTheme;
const { variables, kind } =
THEMES[context.globals.theme as string] ?? THEMES.dark;

useEffect(() => {
const root = document.documentElement.style;
root.setProperty("--vscode-font-family", VSCODE_FONT_FAMILY);

// Mirror VS Code's body attribute so theme-aware hooks work in Storybook.
document.body.setAttribute("data-vscode-theme-kind", kind);

// Apply CSS custom properties to the document root
selectedTheme.forEach(([property, value]) => {
variables.forEach(([property, value]) => {
root.setProperty(property, value);
});

// Cleanup function to remove properties when unmounting
return () => {
selectedTheme.forEach(([property]) => {
variables.forEach(([property]) => {
root.removeProperty(property);
});
root.removeProperty("--vscode-font-family");
document.body.removeAttribute("data-vscode-theme-kind");
};
}, [selectedTheme]);
}, [variables, kind]);

return createElement("div", { id: "root" }, createElement(Story));
},
Expand Down
959 changes: 0 additions & 959 deletions .storybook/themes/dark-v2.ts

This file was deleted.

943 changes: 0 additions & 943 deletions .storybook/themes/light-v2.ts

This file was deleted.

2 changes: 1 addition & 1 deletion .storybook/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"noEmit": true,
"rootDir": ".."
},
"include": ["*.ts", "themes/**/*.ts", "../packages/*/storybook.preview.ts"],
"include": ["*.ts", "../packages/*/storybook.preview.ts"],
"exclude": ["node_modules"]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@
"@typescript-eslint/parser": "^8.63.0",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "^4.1.10",
"@vscode-elements/webview-playground": "catalog:",
"@vscode/codicons": "catalog:",
"@vscode/test-cli": "^0.0.15",
"@vscode/test-electron": "^3.0.0",
Expand Down
36 changes: 36 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# @repo/ui

Generic component library for VS Code webviews. No workspace or task
knowledge β€” that lives in the consuming packages (`@repo/workspaces`,
`@repo/tasks`).

## Theming

`tokens.css` defines semantic `--ui-*` custom properties mapped to the
`--vscode-*` variables VS Code injects into every webview. Components
style against the semantic tokens only, so the VS Code mapping (including
high contrast) is adjusted in one place. Tokens whose underlying variable
may be absent (high contrast themes omit hover/selection fills) declare a
fallback, so every token always resolves. Import it once per webview
entry point:

```ts
import "@repo/ui/tokens.css";
```

`useVscodeTheme()` returns the active theme kind (`dark`, `light`,
`high-contrast`, `high-contrast-light`) and re-renders on theme changes.

`codicon.css` re-exports the codicon font and classes.

## Rules

The package is shaped for a future standalone NPM split. Keep it that way:

- No `workspace:*` runtime dependencies (no `@repo/shared`,
`@repo/webview-shared`).
- `react` stays a peer dependency.
- New entry points go in the `exports` map; consumers never deep-import
`src/`.
- New components define their VS Code mappings in `tokens.css`, not
inline `--vscode-*` references.
29 changes: 29 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@repo/ui",
"version": "1.0.0",
"description": "Shared component library for VS Code webviews",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./codicon.css": "./src/codicon.css",
"./tokens.css": "./src/tokens.css"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@vscode/codicons": "catalog:"
},
"peerDependencies": {
"react": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:",
"react": "catalog:",
"typescript": "catalog:"
}
}
2 changes: 2 additions & 0 deletions packages/ui/src/codicon.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* Codicon assets re-exported so consumers only depend on this package. */
@import "@vscode/codicons/dist/codicon.css";
1 change: 1 addition & 0 deletions packages/ui/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module "*.css";
148 changes: 148 additions & 0 deletions packages/ui/src/foundations.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";

import { useVscodeTheme } from "./useVscodeTheme";

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

/** All `--ui-*` tokens declared in loaded stylesheets, so the story stays in sync with tokens.css. */
function uiTokens(): string[] {
const tokens = new Set<string>();
for (const sheet of Array.from(document.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
continue;
}
for (const rule of Array.from(rules)) {
if (rule instanceof CSSStyleRule && rule.selectorText === ":root") {
for (const property of Array.from(rule.style)) {
if (property.startsWith("--ui-")) {
tokens.add(property);
}
}
}
}
}
return Array.from(tokens);
}

/* Checkerboard so transparent and near-background swatches stay visible. */
const swatchBackdrop: React.CSSProperties = {
width: "1rem",
height: "1rem",
flexShrink: 0,
border: "1px solid var(--ui-description-foreground)",
backgroundImage:
"linear-gradient(45deg, #999 25%, #0000 25% 75%, #999 75%), " +
"linear-gradient(45deg, #999 25%, #0000 25% 75%, #999 75%)",
backgroundSize: "8px 8px",
backgroundPosition: "0 0, 4px 4px",
};

const Foundations = (): React.JSX.Element => {
const theme = useVscodeTheme();
const [values, setValues] = useState<ReadonlyMap<string, string>>(
() => new Map(),
);
const tokens = uiTokens();
const fontTokens = tokens.filter((token) => token.includes("font"));
const colorTokens = tokens.filter((token) => !token.includes("font"));

useEffect(() => {
// The theme decorator applies --vscode-* variables in a parent effect
// that runs after this one; read the resolved values a frame later.
const frame = requestAnimationFrame(() => {
const styles = getComputedStyle(document.documentElement);
setValues(
new Map(
uiTokens().map((token) => [
token,
styles.getPropertyValue(token).trim(),
]),
),
);
});
return (): void => {
cancelAnimationFrame(frame);
};
}, [theme]);

return (
<div
style={{
fontFamily: "var(--ui-font-family)",
fontSize: "var(--ui-font-size)",
fontWeight: "var(--ui-font-weight)",
color: "var(--ui-foreground)",
background: "var(--ui-background)",
border: "1px solid var(--ui-border)",
padding: "1rem",
}}
>
<p>
Theme kind: <code>{theme}</code>
</p>
<p style={{ color: "var(--ui-description-foreground)" }}>
This text is set with {fontTokens.join(", ")}.
</p>
<ul
style={{
listStyle: "none",
padding: 0,
margin: 0,
display: "grid",
gridTemplateColumns: "auto auto 1fr",
gap: "0.25rem 0.75rem",
alignItems: "center",
}}
>
{colorTokens.map((token) => (
<li key={token} style={{ display: "contents" }}>
<span style={swatchBackdrop}>
<span
style={{
display: "block",
width: "100%",
height: "100%",
background: `var(${token})`,
}}
/>
</span>
<code>{token}</code>
<code
style={{
color: "var(--ui-description-foreground)",
backgroundColor: "transparent",
}}
>
{values.get(token) || "unset"}
</code>
</li>
))}
</ul>
</div>
);
};

const meta: Meta<typeof Foundations> = {
title: "UI/Foundations",
component: Foundations,
parameters: {
// Snapshot every theme; tokens are the single theming surface, so this
// is where theme regressions show up.
chromatic: {
modes: {
light: { theme: "light" },
dark: { theme: "dark" },
"high-contrast": { theme: "high-contrast" },
"high-contrast-light": { theme: "high-contrast-light" },
},
},
},
};

export default meta;
type Story = StoryObj<typeof Foundations>;

export const Tokens: Story = {};
1 change: 1 addition & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme";
51 changes: 51 additions & 0 deletions packages/ui/src/tokens.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Semantic tokens mapped to the `--vscode-*` variables VS Code injects
* into webviews β€” the single place the library is theme-matched to
* VS Code.
*/
:root {
/* Typography */
--ui-font-family: var(--vscode-font-family);
--ui-font-size: var(--vscode-font-size);
--ui-font-weight: var(--vscode-font-weight);

/* Surfaces and text */
--ui-foreground: var(--vscode-foreground);
--ui-background: var(
--vscode-sideBar-background,
var(--vscode-editor-background)
);
--ui-description-foreground: var(--vscode-descriptionForeground);
--ui-icon-foreground: var(--vscode-icon-foreground);
--ui-border: var(--vscode-sideBar-border, transparent);

/* Interaction. Hover/selection fills may be absent in high contrast
themes, which convey state through contrast borders instead. */
--ui-focus-border: var(--vscode-focusBorder);
--ui-hover-background: var(--vscode-list-hoverBackground, transparent);
--ui-hover-foreground: var(
--vscode-list-hoverForeground,
var(--ui-foreground)
);
--ui-active-selection-background: var(
--vscode-list-activeSelectionBackground,
transparent
);
--ui-active-selection-foreground: var(
--vscode-list-activeSelectionForeground,
var(--ui-foreground)
);
--ui-inactive-selection-background: var(
--vscode-list-inactiveSelectionBackground,
transparent
);
--ui-link-foreground: var(--vscode-textLink-foreground);

/* Feedback */
--ui-error-foreground: var(--vscode-errorForeground);
--ui-warning-foreground: var(--vscode-editorWarning-foreground);

/* Only defined by high contrast themes; transparent elsewhere */
--ui-contrast-border: var(--vscode-contrastBorder, transparent);
--ui-contrast-active-border: var(--vscode-contrastActiveBorder, transparent);
}
Loading