diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 539d81a6..1f9f3c1c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -101,3 +101,25 @@ jobs:
- name: E2E
run: nr test:e2e
+
+ storybook:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093
+
+ - name: Set node
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
+ with:
+ node-version: lts/*
+
+ - name: Setup
+ run: npm i -g @antfu/ni
+
+ - name: Install
+ run: nci
+
+ - name: Build Storybook
+ run: pnpm -C packages/core run build:storybook
diff --git a/packages/core/.storybook/main.ts b/packages/core/.storybook/main.ts
new file mode 100644
index 00000000..c3b7e728
--- /dev/null
+++ b/packages/core/.storybook/main.ts
@@ -0,0 +1,39 @@
+import type { StorybookConfig } from '@storybook/vue3-vite'
+import { fileURLToPath } from 'node:url'
+import Vue from '@vitejs/plugin-vue'
+import UnoCSS from 'unocss/vite'
+import { mergeConfig } from 'vite'
+import { alias } from '../../../alias'
+
+const config: StorybookConfig = {
+ stories: [
+ '../src/client/**/*.stories.@(ts|tsx)',
+ ],
+ addons: [],
+ framework: {
+ name: '@storybook/vue3-vite',
+ options: {},
+ },
+ core: {
+ disableTelemetry: true,
+ },
+ async viteFinal(base) {
+ return mergeConfig(base, {
+ define: {
+ // The webcomponents entry branches on this flag to import the local
+ // source instead of the published dist. Stories always run against
+ // source, so pin it on.
+ 'import.meta.env.VITE_DEVTOOLS_LOCAL_DEV': JSON.stringify('true'),
+ },
+ resolve: {
+ alias,
+ },
+ plugins: [
+ Vue(),
+ UnoCSS(fileURLToPath(new URL('./uno.config.ts', import.meta.url))),
+ ],
+ })
+ },
+}
+
+export default config
diff --git a/packages/core/.storybook/preview.css b/packages/core/.storybook/preview.css
new file mode 100644
index 00000000..2ba2f8c7
--- /dev/null
+++ b/packages/core/.storybook/preview.css
@@ -0,0 +1,19 @@
+/* Storybook-only chrome. Production styling lives in the shadow-DOM CSS blob. */
+html,
+body,
+#storybook-root {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+}
+
+.vite-devtools-storybook-root {
+ min-height: 100vh;
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ 'Segoe UI',
+ Roboto,
+ sans-serif;
+}
diff --git a/packages/core/.storybook/preview.ts b/packages/core/.storybook/preview.ts
new file mode 100644
index 00000000..bf165d35
--- /dev/null
+++ b/packages/core/.storybook/preview.ts
@@ -0,0 +1,57 @@
+import type { Preview } from '@storybook/vue3-vite'
+
+import '@unocss/reset/tailwind.css'
+import '@xterm/xterm/css/xterm.css'
+import 'uno.css'
+import './preview.css'
+
+type Theme = 'dark' | 'light'
+
+function applyTheme(theme: Theme) {
+ const root = document.documentElement
+ root.classList.toggle('dark', theme === 'dark')
+ root.style.colorScheme = theme
+}
+
+const preview: Preview = {
+ parameters: {
+ layout: 'fullscreen',
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+ globalTypes: {
+ theme: {
+ description: 'DevTools color theme',
+ toolbar: {
+ title: 'Theme',
+ icon: 'circlehollow',
+ items: [
+ { value: 'dark', title: 'Dark', icon: 'moon' },
+ { value: 'light', title: 'Light', icon: 'sun' },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ initialGlobals: {
+ theme: 'dark',
+ },
+ decorators: [
+ (story, context) => {
+ applyTheme((context.globals.theme as Theme) ?? 'dark')
+ // Wrap via a template so Storybook composes the StoryFn into a real
+ // component. Rendering the raw `story` fn through `h()` coerces its
+ // return to a text node ("[object Object]").
+ return {
+ components: { story },
+ template: `
`,
+ }
+ },
+ ],
+}
+
+export default preview
diff --git a/packages/core/.storybook/uno.config.ts b/packages/core/.storybook/uno.config.ts
new file mode 100644
index 00000000..965d23cf
--- /dev/null
+++ b/packages/core/.storybook/uno.config.ts
@@ -0,0 +1,54 @@
+import { fileURLToPath } from 'node:url'
+import { sharedShortcuts } from '@vitejs/devtools-ui/unocss/shared-shortcuts'
+import { theme } from '@vitejs/devtools-ui/unocss/theme'
+import {
+ defineConfig,
+ presetIcons,
+ presetWind3,
+ transformerDirectives,
+} from 'unocss'
+
+// Mirrors the webcomponents UnoCSS config (`src/client/webcomponents/uno.config.ts`)
+// with two Storybook-specific tweaks:
+// 1. `dark: 'class'` so a toolbar toggle can flip the theme via a `.dark`
+// class (production uses `media`, baked into the shadow-DOM CSS blob).
+// 2. filesystem scanning of the component sources + stories, since the
+// components render in the light DOM here and UnoCSS runs live.
+export default defineConfig({
+ shortcuts: [
+ ...sharedShortcuts,
+ {
+ 'z-viewframe': 'z-20',
+ 'z-viewframe-resizer': 'z-30',
+ 'z-floating-dock': 'z-50',
+ 'z-floating-anchor': 'z-[2147483644]',
+ 'z-floating-tooltip': 'z-[2147483645]',
+ 'z-command-palette': 'z-[2147483646]',
+ },
+ ],
+ transformers: [
+ transformerDirectives(),
+ ],
+ theme: {
+ colors: theme.colors,
+ },
+ presets: [
+ presetWind3({
+ dark: 'class',
+ variablePrefix: 'vdt-',
+ }),
+ presetIcons({
+ warn: true,
+ collections: {
+ logos: () => import('@iconify-json/logos').then(i => i.icons),
+ ph: () => import('@iconify-json/ph').then(i => i.icons),
+ },
+ }),
+ ],
+ content: {
+ filesystem: [
+ fileURLToPath(new URL('../src/client/webcomponents/components/**/*.{vue,ts}', import.meta.url)),
+ fileURLToPath(new URL('../src/client/**/*.stories.ts', import.meta.url)),
+ ],
+ },
+})
diff --git a/packages/core/package.json b/packages/core/package.json
index 82712a10..64ba2100 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -50,7 +50,9 @@
"play:debug": "pnpm -C playground run dev:debug",
"play:standalone": "DEBUG='vite:devtools:*' pnpm -C playground run dev:standalone",
"cli": "DEBUG='vite:devtools:*' tsx src/node/cli.ts",
- "play:build": "pnpm -C playground run build"
+ "play:build": "pnpm -C playground run build",
+ "storybook": "storybook dev -p 6006 --host 0.0.0.0",
+ "build:storybook": "storybook build -o dist/storybook"
},
"peerDependencies": {
"vite": "*"
@@ -72,6 +74,8 @@
},
"devDependencies": {
"@clack/prompts": "catalog:inlined",
+ "@storybook/vue3-vite": "catalog:devtools",
+ "@unocss/reset": "catalog:build",
"@vitejs/devtools-rolldown": "workspace:*",
"@vitejs/plugin-vue": "catalog:build",
"@xterm/addon-fit": "catalog:frontend",
@@ -80,8 +84,10 @@
"fuse.js": "catalog:frontend",
"human-id": "catalog:inlined",
"iframe-pane": "catalog:frontend",
+ "storybook": "catalog:devtools",
"tsdown": "catalog:build",
"typescript": "catalog:devtools",
+ "unocss": "catalog:build",
"unplugin-vue": "catalog:build",
"unrun": "catalog:build",
"vite": "catalog:build",
diff --git a/packages/core/src/client/inject/index.ts b/packages/core/src/client/inject/index.ts
index 44573c0a..9417f32b 100644
--- a/packages/core/src/client/inject/index.ts
+++ b/packages/core/src/client/inject/index.ts
@@ -38,10 +38,11 @@ export async function init(): Promise {
)
;(globalThis as any)[CLIENT_CONTEXT_KEY] = context
- const { DockEmbedded } = import.meta.env.VITE_DEVTOOLS_LOCAL_DEV
+ const { registerDockEmbedded } = import.meta.env.VITE_DEVTOOLS_LOCAL_DEV
? await import('../webcomponents')
: await import('@vitejs/devtools/client/webcomponents')
+ const DockEmbedded = registerDockEmbedded()
const dockEl = new DockEmbedded({ context })
document.body.appendChild(dockEl)
}
diff --git a/packages/core/src/client/webcomponents/components/DockEmbedded.ts b/packages/core/src/client/webcomponents/components/DockEmbedded.ts
index f08fa9ed..e81b0720 100644
--- a/packages/core/src/client/webcomponents/components/DockEmbedded.ts
+++ b/packages/core/src/client/webcomponents/components/DockEmbedded.ts
@@ -4,6 +4,16 @@ import { defineCustomElement } from 'vue'
import css from '../.generated/css'
import Component from './dock/DockEmbedded.vue'
+export const DOCK_EMBEDDED_TAG = 'vite-devtools-dock-embedded'
+
+/**
+ * The embedded dock custom-element constructor.
+ *
+ * `defineCustomElement` is pure — it only builds the class. Registering the
+ * element with the global `customElements` registry is deferred to
+ * {@link registerDockEmbedded} so importing this module stays side-effect-free
+ * (honoring the package's `sideEffects: false`).
+ */
export const DockEmbedded = defineCustomElement(
Component,
{
@@ -14,4 +24,16 @@ export const DockEmbedded = defineCustomElement(
context: DocksContext
}>
-customElements.define('vite-devtools-dock-embedded', DockEmbedded)
+/**
+ * Register the embedded dock as a custom element and return its constructor.
+ *
+ * Idempotent and safe to call in environments without a `customElements`
+ * registry (returns the constructor unchanged). Instantiating the constructor
+ * with `new` requires the element to be registered first, so call this before
+ * constructing one.
+ */
+export function registerDockEmbedded(): VueElementConstructor<{ context: DocksContext }> {
+ if (typeof customElements !== 'undefined' && !customElements.get(DOCK_EMBEDDED_TAG))
+ customElements.define(DOCK_EMBEDDED_TAG, DockEmbedded)
+ return DockEmbedded
+}
diff --git a/packages/core/src/client/webcomponents/components/DockStandalone.ts b/packages/core/src/client/webcomponents/components/DockStandalone.ts
index 8614ac1b..facf199c 100644
--- a/packages/core/src/client/webcomponents/components/DockStandalone.ts
+++ b/packages/core/src/client/webcomponents/components/DockStandalone.ts
@@ -4,6 +4,16 @@ import { defineCustomElement } from 'vue'
import css from '../.generated/css'
import Component from './dock/DockStandalone.vue'
+export const DOCK_STANDALONE_TAG = 'vite-devtools-dock-standalone'
+
+/**
+ * The standalone dock custom-element constructor.
+ *
+ * `defineCustomElement` is pure — it only builds the class. Registering the
+ * element with the global `customElements` registry is deferred to
+ * {@link registerDockStandalone} so importing this module stays
+ * side-effect-free (honoring the package's `sideEffects: false`).
+ */
export const DockStandalone = defineCustomElement(
Component,
{
@@ -14,5 +24,16 @@ export const DockStandalone = defineCustomElement(
context: DocksContext
}>
-if (!customElements.get('vite-devtools-dock-standalone'))
- customElements.define('vite-devtools-dock-standalone', DockStandalone)
+/**
+ * Register the standalone dock as a custom element and return its constructor.
+ *
+ * Idempotent and safe to call in environments without a `customElements`
+ * registry (returns the constructor unchanged). Instantiating the constructor
+ * with `new` requires the element to be registered first, so call this before
+ * constructing one.
+ */
+export function registerDockStandalone(): VueElementConstructor<{ context: DocksContext }> {
+ if (typeof customElements !== 'undefined' && !customElements.get(DOCK_STANDALONE_TAG))
+ customElements.define(DOCK_STANDALONE_TAG, DockStandalone)
+ return DockStandalone
+}
diff --git a/packages/core/src/client/webcomponents/state/messages.ts b/packages/core/src/client/webcomponents/state/messages.ts
index 14a5794f..22d6c08f 100644
--- a/packages/core/src/client/webcomponents/state/messages.ts
+++ b/packages/core/src/client/webcomponents/state/messages.ts
@@ -74,6 +74,15 @@ export function useMessages(context: DocksContext): Reactive {
return state
}
+/**
+ * Reset the module-level messages singleton. Intended for tests and stories,
+ * which share one module graph — call before re-initializing `useMessages`
+ * with a fresh context so state doesn't leak between scenarios.
+ */
+export function resetMessagesState(): void {
+ _messagesState = undefined
+}
+
export function markMessagesAsRead(): void {
if (_messagesState)
_messagesState.unreadCount = 0
diff --git a/packages/core/src/client/webcomponents/state/popup.ts b/packages/core/src/client/webcomponents/state/popup.ts
index b459ef86..cc09751e 100644
--- a/packages/core/src/client/webcomponents/state/popup.ts
+++ b/packages/core/src/client/webcomponents/state/popup.ts
@@ -27,7 +27,7 @@ let detachColorModeSync: (() => void) | undefined
let popupDockElement: (HTMLElement & { remove: () => void }) | undefined
let popupContext: DocksContext | undefined
let loadDockStandalone: () => Promise HTMLElement> = async () => {
- return await import('../components/DockStandalone').then(m => m.DockStandalone)
+ return await import('../components/DockStandalone').then(m => m.registerDockStandalone())
}
popupEvents.on('popup:open-requested', (context) => {
@@ -250,7 +250,7 @@ export function closeDockPopup() {
export function setDockStandaloneLoaderForTest(loader?: () => Promise HTMLElement>) {
loadDockStandalone = loader || (async () => {
- return await import('../components/DockStandalone').then(m => m.DockStandalone)
+ return await import('../components/DockStandalone').then(m => m.registerDockStandalone())
})
}
diff --git a/packages/core/src/client/webcomponents/state/terminals.ts b/packages/core/src/client/webcomponents/state/terminals.ts
index d023474f..1ed1ddef 100644
--- a/packages/core/src/client/webcomponents/state/terminals.ts
+++ b/packages/core/src/client/webcomponents/state/terminals.ts
@@ -13,6 +13,16 @@ export interface TerminalState {
}
let _terminalsMap: Reactive