diff --git a/examples/storybook-hub/README.md b/examples/storybook-hub/README.md deleted file mode 100644 index 78c4c82c..00000000 --- a/examples/storybook-hub/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# storybook-hub - -A devframe hub, built on `@devframes/hub`, that surfaces every built-in plugin's -Storybook as its own dock — plus the live terminals plugin running as a real -integration. It's a second take on the unified Storybook: instead of Storybook -Composition, the **hub** is the shell and each Storybook is a lazily-mounted -iframe dock (the same on-demand embed pattern the code-server plugin uses). - -## How it works - -The whole host is one Vite plugin (`src/storybook-hub.ts`): it creates a hub -context, implements the framework-neutral `DevframeHost`, registers a dock per -plugin Storybook, mounts the terminals plugin via `mountDevframe`, and starts a -side-car RPC/WS server. - -Each Storybook dock's iframe is created **only when the dock is first opened**, -then kept mounted so its state survives tab switches. Where the iframe points -depends on the mode, unified behind the `storybook-hub:ensure` RPC: - -- **dev** (`vite`) — the plugin's `storybook dev` server is spawned on first - open and the dock iframes it live (HMR). The process is launched through - `ctx.terminals`, the hub's terminals subsystem, so each spawned Storybook is - a read-only terminal session — open the **Terminals** dock to watch its - output stream live. -- **build** (`vite preview`) — the pre-built `storybook/storybook-static/` - is served by the hub on one origin and the dock iframes that. - -## Run it - -Build the plugin SPAs the hub mounts (terminals) once: - -```sh -pnpm build -``` - -### Dev — Storybooks spawned on demand - -```sh -pnpm --filter storybook-hub dev -``` - -Open the printed URL, then click a Storybook in the sidebar; its dev server -boots on first open (subsequent opens are instant). The dev servers listen on -their own ports, so reaching them from a remote browser needs those ports -forwarded. - -### Preview — pre-built Storybooks on one origin - -```sh -pnpm storybook:build # produces storybook/storybook-static/ -pnpm --filter storybook-hub build -pnpm --filter storybook-hub preview -``` - -Everything is served from the single preview origin, so one forwarded port -reaches the whole hub. diff --git a/examples/storybook-hub/package.json b/examples/storybook-hub/package.json deleted file mode 100644 index c4eef106..00000000 --- a/examples/storybook-hub/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "storybook-hub", - "type": "module", - "version": "0.7.14", - "private": true, - "description": "Example — a devframe hub that docks every built-in plugin's Storybook (lazily spawned in dev, served static in build) alongside the live terminals plugin.", - "homepage": "https://github.com/devframes/devframe/tree/main/examples/storybook-hub", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@antfu/design": "catalog:frontend", - "@devframes/hub": "workspace:*", - "@devframes/plugin-terminals": "workspace:*", - "colorjs.io": "catalog:frontend", - "devframe": "workspace:*", - "iframe-pane": "catalog:frontend" - }, - "devDependencies": { - "@iconify-json/ph": "catalog:frontend", - "get-port-please": "catalog:deps", - "pathe": "catalog:deps", - "storybook": "catalog:storybook", - "unocss": "catalog:frontend", - "vite": "catalog:build" - } -} diff --git a/examples/storybook-hub/src/client/main.ts b/examples/storybook-hub/src/client/main.ts deleted file mode 100644 index 06c7dd5f..00000000 --- a/examples/storybook-hub/src/client/main.ts +++ /dev/null @@ -1,212 +0,0 @@ -import type { DevframeDockEntry } from '@devframes/hub/types' -import { connectDevframe } from '@devframes/hub/client' -import { createIframePanes } from 'iframe-pane' -import { iconClass } from './icons' -import 'virtual:uno.css' -import '@antfu/design/styles.css' - -const HUB_BASE = '/__hub/' - -// Mirror of the host's `storybook-hub:ensure` return shape. -type EnsureResult - = | { ok: true, kind: 'port', port: number } - | { ok: true, kind: 'path', url: string } - | { ok: false, error: string } - -type IframeDock = DevframeDockEntry & { type: 'iframe', url: string } - -/** Sidebar section order; anything else follows alphabetically. */ -const CATEGORY_ORDER = ['Storybooks', 'Plugins'] - -const connEl = document.querySelector('#conn')! -const docksEl = document.querySelector('#docks')! -const stageEl = document.querySelector('#stage')! -const overlayEl = document.querySelector('#overlay')! - -interface DockRuntime { - status: 'idle' | 'starting' | 'ready' | 'error' - error?: string -} - -// Every opened dock's iframe is parked here for its whole lifetime — switching -// tabs only mounts/unmounts the pane over `#stage`, so background docks keep -// their state (Storybook's own routing, scroll, etc.) intact. -const panes = createIframePanes({ container: stageEl }) -const runtimes = new Map() -let docks: IframeDock[] = [] -let selectedId: string | null = null - -function setStatus(text: string, kind?: 'ready' | 'error') { - const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400' - connEl.innerHTML = `${text}` -} - -function isIframeDock(d: DevframeDockEntry): d is IframeDock { - return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' -} - -function isStorybookDock(id: string): boolean { - return id.startsWith('sb-') -} - -function runtimeFor(id: string): DockRuntime { - let rt = runtimes.get(id) - if (!rt) { - rt = { status: 'idle' } - runtimes.set(id, rt) - } - return rt -} - -function dockIcon(entry: DevframeDockEntry): string { - const cls = iconClass(entry.icon) - if (cls) - return `` - const initial = (entry.title?.[0] ?? '?').toUpperCase() - return `${initial}` -} - -function overlay(kind: 'spin' | 'error' | 'idle', title: string, detail = '') { - const glyph = kind === 'spin' - ? '' - : kind === 'error' - ? '' - : '' - overlayEl.style.display = 'flex' - overlayEl.innerHTML = `
${glyph}
${title}
${detail ? `
${detail}
` : ''}
` -} - -function updateStage() { - for (const pane of panes.list()) { - if (pane.id === selectedId) - pane.mount(stageEl) - else - pane.unmount() - } - - if (!selectedId) { - overlay('idle', 'No dock selected') - return - } - const rt = runtimes.get(selectedId) - const title = docks.find(d => d.id === selectedId)?.title ?? selectedId - if (!rt || rt.status === 'starting' || (rt.status !== 'error' && !panes.has(selectedId))) { - overlay('spin', isStorybookDock(selectedId) ? `Starting ${title} Storybook…` : `Loading ${title}…`) - return - } - if (rt.status === 'error') { - overlay('error', `Failed to start ${title}`, rt.error) - return - } - overlayEl.style.display = 'none' -} - -async function ensureUrl(rpc: Awaited>, entry: IframeDock): Promise { - // Live plugin docks already carry a hub-served URL; only Storybook docks are - // resolved on demand (spawned in dev, static in build). - if (!isStorybookDock(entry.id)) - return entry.url - - const result = await rpc.call('storybook-hub:ensure' as any, { id: entry.id.slice(3) }) as EnsureResult - if (!result.ok) - throw new Error(result.error) - return result.kind === 'path' - ? result.url - : `${location.protocol}//${location.hostname}:${result.port}/` -} - -function initDock(rpc: Awaited>, entry: IframeDock) { - const rt = runtimeFor(entry.id) - if (rt.status !== 'idle') - return - rt.status = 'starting' - updateStage() - - ensureUrl(rpc, entry) - .then((url) => { - panes.ensure(entry.id, { - src: url, - attrs: { title: entry.title, allow: 'clipboard-read; clipboard-write' }, - style: { border: '0' }, - onCreated: (iframe) => { - iframe.addEventListener('load', () => { - rt.status = 'ready' - updateStage() - }) - }, - }) - updateStage() - }) - .catch((err: Error) => { - rt.status = 'error' - rt.error = err.message - updateStage() - }) -} - -async function main() { - setStatus('Connecting…') - const rpc = await connectDevframe({ baseURL: HUB_BASE }) - setStatus(`Connected · backend=${rpc.connectionMeta.backend}`, 'ready') - - const switchTo = (id: string) => { - if (!docks.some(d => d.id === id)) - return - selectedId = id - renderSidebar() - const rt = runtimeFor(id) - if (rt.status === 'idle') - initDock(rpc, docks.find(d => d.id === id)!) - updateStage() - } - - function renderSidebar() { - if (!docks.length) { - docksEl.innerHTML = '
  • No docks yet…
  • ' - return - } - const categories = [...new Set(docks.map(d => d.category ?? 'Other'))].sort( - (a, b) => { - const ia = CATEGORY_ORDER.indexOf(a) - const ib = CATEGORY_ORDER.indexOf(b) - return (ia === -1 ? Infinity : ia) - (ib === -1 ? Infinity : ib) || a.localeCompare(b) - }, - ) - docksEl.innerHTML = categories.map((category) => { - const items = docks.filter(d => (d.category ?? 'Other') === category) - const buttons = items.map(d => - `
  • `).join('') - return `
  • ${category}
  • ${buttons}` - }).join('') - } - - // Docks — read from `devframe:docks` shared state. - const docksState = await rpc.sharedState.get('devframe:docks', { initialValue: [] }) - const syncDocks = () => { - docks = (docksState.value() ?? []).filter(isIframeDock) - if (selectedId && !docks.some(d => d.id === selectedId)) - selectedId = null - if (!selectedId && docks.length) - selectedId = docks[0].id - renderSidebar() - if (selectedId) { - const rt = runtimeFor(selectedId) - if (rt.status === 'idle') - initDock(rpc, docks.find(d => d.id === selectedId)!) - } - updateStage() - } - docksState.on('updated', syncDocks) - - docksEl.addEventListener('click', (event) => { - const target = (event.target as HTMLElement).closest('button[data-dock-id]') - if (target?.dataset.dockId) - switchTo(target.dataset.dockId) - }) - syncDocks() -} - -main().catch((err) => { - setStatus(`Failed: ${(err as Error).message}`, 'error') - console.error(err) -}) diff --git a/examples/storybook-hub/tsconfig.json b/examples/storybook-hub/tsconfig.json deleted file mode 100644 index 46dbffd9..00000000 --- a/examples/storybook-hub/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "lib": ["ESNext", "DOM"], - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true, - "esModuleInterop": true, - "isolatedDeclarations": false - }, - "include": ["src", "vite.config.ts"] -} diff --git a/examples/storybook-hub/uno.config.ts b/examples/storybook-hub/uno.config.ts deleted file mode 100644 index be8319c2..00000000 --- a/examples/storybook-hub/uno.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { mergeConfigs } from 'unocss' -import { designConfig } from '../../design/uno.config' - -// The hub UI composes the shared devframe base (see `design/uno.config.ts`). -// Pair with `@antfu/design/styles.css` (imported in `src/client/main.ts`). `.ts` -// is opted into extraction since the hub authors its class strings in vanilla -// `src/client/main.ts`. -export default mergeConfigs([ - designConfig, - { - content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } }, - }, -]) diff --git a/package.json b/package.json index 6bbdb325..dd82f9bc 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "docs": "pnpm -C docs run docs", "docs:build": "pnpm -C docs run docs:build", "docs:serve": "pnpm -C docs run docs:serve", - "storybook": "pnpm -r --parallel --if-present run storybook", + "storybook": "pnpm -C storybook dev", "storybook:build": "pnpm --filter @devframes/storybook run storybook:build", "lint": "eslint --cache", "test": "pnpm run build && vitest", diff --git a/packages/hub/src/node/__tests__/host-docks.test.ts b/packages/hub/src/node/__tests__/host-docks.test.ts index 1db4d7e7..392b6b6b 100644 --- a/packages/hub/src/node/__tests__/host-docks.test.ts +++ b/packages/hub/src/node/__tests__/host-docks.test.ts @@ -1,3 +1,4 @@ +import type { DevframeViewLauncher } from '../../types/docks' import type { DevframeHubContext } from '../context' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -305,4 +306,30 @@ describe('devframeDockHost ~builtin category', () => { expect(updatedLauncher?.digest).toBe('done') expect(updatedLauncher?.status).toBe('success') }) + + it('updates a registered entry whose stored view was frozen (Immer-projected shared state)', () => { + const host = new DevframeDocksHost(createContext()) + const handle = host.register({ + type: 'launcher', + id: 'app:build', + title: 'Build', + icon: 'ph:hammer-duotone', + launcher: { title: 'Run build', status: 'idle', command: 'app:run-build' }, + }) + + // Projecting the entry into `devframe:docks` shared state runs it through + // Immer's `produce`, which deep-freezes the very object the registry holds. + Object.freeze(host.values()[0]) + + // The launch handler patches the launcher as the process boots; the update + // must not throw "Cannot assign to read only property" on the frozen entry. + expect(() => handle.update({ + launcher: { title: 'Run build', status: 'loading', command: 'app:run-build', terminalSessionId: 'build-session', digest: 'compiling…' }, + })).not.toThrow() + + const entry = host.values()[0] + const launcher = entry.type === 'launcher' ? entry.launcher : undefined + expect(launcher?.status).toBe('loading') + expect(launcher?.digest).toBe('compiling…') + }) }) diff --git a/packages/hub/src/node/host-docks.ts b/packages/hub/src/node/host-docks.ts index b3116207..f89215c9 100644 --- a/packages/hub/src/node/host-docks.ts +++ b/packages/hub/src/node/host-docks.ts @@ -148,7 +148,12 @@ export class DevframeDocksHost implements DevframeDocksHostType { if (patch.id && patch.id !== view.id) { throw diagnostics.DF8101({ id: view.id, attempted: patch.id }) } - this.update(Object.assign(this.views.get(view.id)!, patch)) + // Merge into a fresh object rather than mutating the stored entry in + // place: once projected into the `devframe:docks` shared state, the + // stored entry is deep-frozen by Immer, so an in-place `Object.assign` + // would throw "Cannot assign to read only property". A new object is + // always writable, and `update()` swaps it into the registry. + this.update({ ...this.views.get(view.id)!, ...patch } as DevframeDockUserEntry) }, } } diff --git a/plugins/data-inspector/.storybook/main.ts b/plugins/data-inspector/.storybook/main.ts new file mode 100644 index 00000000..28cbadfa --- /dev/null +++ b/plugins/data-inspector/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { mergeConfig } from 'vite' +import { alias } from '../../../alias' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: {}, + async viteFinal(config) { + return mergeConfig(config, { + resolve: { alias }, + plugins: [vue(), UnoCSS()], + // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, + // tailnets), e.g. when iframed by the storybook hub. + server: { allowedHosts: true }, + }) + }, +} +export default config diff --git a/storybook/.storybook/preview.ts b/plugins/data-inspector/.storybook/preview.ts similarity index 61% rename from storybook/.storybook/preview.ts rename to plugins/data-inspector/.storybook/preview.ts index 2b1ef963..bab616f2 100644 --- a/storybook/.storybook/preview.ts +++ b/plugins/data-inspector/.storybook/preview.ts @@ -1,10 +1,11 @@ -import type { Decorator, Preview } from '@storybook/html-vite' +import type { Decorator, Preview } from '@storybook/vue3-vite' import 'virtual:uno.css' import '@antfu/design/styles.css' +import '../src/spa/style.css' -// The host shell tracks the same theme convention as every composed plugin: dark -// mode is the `.dark` class on ``, and the canvas takes the semantic -// `bg-base`/`color-base` surface. +// Drive the shared `@antfu/design` tokens off the toolbar theme toggle: dark mode +// is the `.dark` class on ``, and the canvas takes the semantic +// `bg-base`/`color-base` surface — matching every other devframe surface. function applyTheme(theme: string): void { document.documentElement.classList.toggle('dark', theme !== 'light') document.body.classList.add('bg-base', 'color-base', 'font-sans') @@ -12,13 +13,19 @@ function applyTheme(theme: string): void { const withTheme: Decorator = (story, context) => { applyTheme(context.globals.theme ?? 'dark') - return story(context) + return { components: { story }, template: '' } } const preview: Preview = { parameters: { layout: 'fullscreen', - controls: { expanded: true }, + controls: { + expanded: true, + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, }, globalTypes: { theme: { diff --git a/plugins/data-inspector/package.json b/plugins/data-inspector/package.json index a79d4d4f..f73339d1 100644 --- a/plugins/data-inspector/package.json +++ b/plugins/data-inspector/package.json @@ -43,6 +43,8 @@ "build": "tsdown && vite build --config src/spa/vite.config.ts", "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", "watch": "tsdown --watch", + "storybook": "storybook dev -p 6016 --host 0.0.0.0", + "build-storybook": "storybook build", "prepack": "pnpm build", "test": "vitest run", "typecheck": "tsc --noEmit" @@ -66,6 +68,8 @@ "@antfu/design": "catalog:frontend", "@discoveryjs/discovery": "catalog:frontend", "@iconify-json/ph": "catalog:frontend", + "@storybook/addon-docs": "catalog:storybook", + "@storybook/vue3-vite": "catalog:storybook", "@types/codemirror": "catalog:types", "@vitejs/plugin-vue": "catalog:build", "devframe": "workspace:*", @@ -73,6 +77,7 @@ "floating-vue": "catalog:frontend", "reka-ui": "catalog:frontend", "splitpanes": "catalog:frontend", + "storybook": "catalog:storybook", "tsdown": "catalog:build", "unocss": "catalog:frontend", "vite": "catalog:build", diff --git a/plugins/data-inspector/src/spa/components/DataShapePanel.stories.ts b/plugins/data-inspector/src/spa/components/DataShapePanel.stories.ts new file mode 100644 index 00000000..abd69153 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/DataShapePanel.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { sampleSkeleton, sampleSources } from './_fixtures' +import DataShapePanel from './DataShapePanel.vue' + +const meta = { + title: 'DataInspector/DataShapePanel', + component: DataShapePanel, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Overview: Story = { + args: { + source: sampleSources[0], + skeleton: sampleSkeleton, + error: null, + loading: false, + }, +} + +export const Loading: Story = { + args: { + source: sampleSources[0], + skeleton: null, + error: null, + loading: true, + }, +} + +export const Errored: Story = { + args: { + source: sampleSources[0], + skeleton: null, + error: 'Failed to resolve the data source skeleton.', + loading: false, + }, +} diff --git a/plugins/data-inspector/src/spa/components/DataSourceSelect.stories.ts b/plugins/data-inspector/src/spa/components/DataSourceSelect.stories.ts new file mode 100644 index 00000000..5b3ac24b --- /dev/null +++ b/plugins/data-inspector/src/spa/components/DataSourceSelect.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' +import { sampleSources } from './_fixtures' +import DataSourceSelect from './DataSourceSelect.vue' + +const meta = { + title: 'DataInspector/DataSourceSelect', + component: DataSourceSelect, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { DataSourceSelect }, + setup() { + const model = ref('devframe') + return { model, sources: sampleSources } + }, + template: `
    `, + }), +} + +export const Placeholder: Story = { + render: () => ({ + components: { DataSourceSelect }, + setup() { + const model = ref() + return { model, sources: sampleSources } + }, + template: `
    `, + }), +} diff --git a/plugins/data-inspector/src/spa/components/QuerySettings.stories.ts b/plugins/data-inspector/src/spa/components/QuerySettings.stories.ts new file mode 100644 index 00000000..6f4d16a2 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/QuerySettings.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' +import { sampleFilters } from './_fixtures' +import QuerySettings from './QuerySettings.vue' + +const meta = { + title: 'DataInspector/QuerySettings', + component: QuerySettings, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { QuerySettings }, + setup() { + const settings = ref({ ...sampleFilters }) + const autoRun = ref(false) + const autoRunSeconds = ref(5) + return { settings, autoRun, autoRunSeconds } + }, + template: `
    `, + }), +} + +export const AutoRunEnabled: Story = { + render: () => ({ + components: { QuerySettings }, + setup() { + const settings = ref({ excludeFunctions: true, excludeUnderscoreProps: true, excludeDollarProps: false }) + const autoRun = ref(true) + const autoRunSeconds = ref(10) + return { settings, autoRun, autoRunSeconds } + }, + template: `
    `, + }), +} diff --git a/plugins/data-inspector/src/spa/components/SavedQueriesPanel.stories.ts b/plugins/data-inspector/src/spa/components/SavedQueriesPanel.stories.ts new file mode 100644 index 00000000..2c238ad0 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/SavedQueriesPanel.stories.ts @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { sampleFilters, sampleSaved, sampleSuggested } from './_fixtures' +import SavedQueriesPanel from './SavedQueriesPanel.vue' + +const meta = { + title: 'DataInspector/SavedQueriesPanel', + component: SavedQueriesPanel, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const WithSavedAndSuggested: Story = { + args: { + saved: sampleSaved, + suggested: sampleSuggested, + currentQuery: 'build.modules.size()', + currentFilters: sampleFilters, + }, +} + +export const SuggestedOnly: Story = { + args: { + saved: [], + suggested: sampleSuggested, + currentQuery: 'os.platform', + currentFilters: sampleFilters, + }, +} + +export const Readonly: Story = { + args: { + saved: sampleSaved, + suggested: sampleSuggested, + currentQuery: 'build.modules.size()', + currentFilters: sampleFilters, + readonly: true, + }, +} diff --git a/plugins/data-inspector/src/spa/components/_fixtures.ts b/plugins/data-inspector/src/spa/components/_fixtures.ts new file mode 100644 index 00000000..0cba0533 --- /dev/null +++ b/plugins/data-inspector/src/spa/components/_fixtures.ts @@ -0,0 +1,64 @@ +import type { DataSourceMeta, FilterOptions, Query, SavedQuery } from '../../engine' + +// Shared, static sample data so the presentational components render in +// isolation without a live RPC connection to a server-side data source. + +export const sampleSources: DataSourceMeta[] = [ + { + id: 'devframe', + title: 'Devframe Context', + description: 'Runtime context, OS and process info, plus a playground graph.', + icon: 'i-ph:cube-duotone', + static: false, + queries: [ + { query: 'playground.requests.entries.mapEntries().value.sort(hits desc)', title: 'Top requests' }, + { query: 'playground.build.modules.[sizeKb > 80].({ id, sizeKb })', title: 'Heavy modules' }, + ], + }, + { + id: 'build', + title: 'Build Modules', + description: 'The production module graph from the last build.', + icon: 'i-ph:package-duotone', + static: true, + }, +] + +/** A one-level type skeleton, as the shape panel receives from the backend. */ +export const sampleSkeleton: Record = { + requests: 'Map(3)', + middlewares: 'array', + build: 'object', + os: 'object', + uptimeMs: 'number', + startedAt: 'Date', +} + +export const sampleFilters: Required = { + excludeFunctions: true, + excludeUnderscoreProps: false, + excludeDollarProps: false, +} + +export const sampleSuggested: Query[] = [ + { query: 'playground.requests.size()', title: 'Request count' }, + { query: 'os.({ platform, arch, release })', title: 'OS summary' }, +] + +export const sampleSaved: SavedQuery[] = [ + { + id: 'heavy-modules', + query: 'build.modules.[sizeKb > 80].({ id, sizeKb }).sort(sizeKb desc)', + title: 'Heavy modules', + description: 'Modules over 80 kB, largest first.', + scope: 'project', + updatedAt: Date.now() - 1000 * 60 * 12, + }, + { + id: 'slow-requests', + query: 'playground.requests.entries.mapEntries().value.[ms > 100]', + title: 'Slow requests', + scope: 'workspace', + updatedAt: Date.now() - 1000 * 60 * 60 * 26, + }, +] diff --git a/plugins/og/.storybook/main.ts b/plugins/og/.storybook/main.ts new file mode 100644 index 00000000..28cbadfa --- /dev/null +++ b/plugins/og/.storybook/main.ts @@ -0,0 +1,25 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { mergeConfig } from 'vite' +import { alias } from '../../../alias' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: {}, + async viteFinal(config) { + return mergeConfig(config, { + resolve: { alias }, + plugins: [vue(), UnoCSS()], + // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, + // tailnets), e.g. when iframed by the storybook hub. + server: { allowedHosts: true }, + }) + }, +} +export default config diff --git a/plugins/og/.storybook/preview.ts b/plugins/og/.storybook/preview.ts new file mode 100644 index 00000000..b2bef5cd --- /dev/null +++ b/plugins/og/.storybook/preview.ts @@ -0,0 +1,48 @@ +import type { Decorator, Preview } from '@storybook/vue3-vite' +import 'virtual:uno.css' +import '@antfu/design/styles.css' +import '../src/spa/app/assets/main.css' + +// Drive the shared `@antfu/design` tokens off the toolbar theme toggle: dark mode +// is the `.dark` class on ``, and the canvas takes the semantic +// `bg-base`/`color-base` surface — matching every other devframe surface. +function applyTheme(theme: string): void { + document.documentElement.classList.toggle('dark', theme !== 'light') + document.body.classList.add('bg-base', 'color-base', 'font-sans') +} + +const withTheme: Decorator = (story, context) => { + applyTheme(context.globals.theme ?? 'dark') + return { components: { story }, template: '' } +} + +const preview: Preview = { + parameters: { + layout: 'fullscreen', + controls: { + expanded: true, + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'dark', + toolbar: { + title: 'Theme', + icon: 'contrast', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [withTheme], +} + +export default preview diff --git a/plugins/og/package.json b/plugins/og/package.json index 093763a8..1476a535 100644 --- a/plugins/og/package.json +++ b/plugins/og/package.json @@ -41,6 +41,8 @@ "build": "tsdown && vite build --config src/spa/vite.config.ts", "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", "watch": "tsdown --watch", + "storybook": "storybook dev -p 6017 --host 0.0.0.0", + "build-storybook": "storybook build", "cli:build": "node bin.mjs build --out-dir dist/static", "prepack": "pnpm build", "test": "vitest run", @@ -64,11 +66,14 @@ "devDependencies": { "@antfu/design": "catalog:frontend", "@iconify-json/ph": "catalog:frontend", + "@storybook/addon-docs": "catalog:storybook", + "@storybook/vue3-vite": "catalog:storybook", "@types/node": "catalog:types", "@vitejs/plugin-vue": "catalog:build", "devframe": "workspace:*", "get-port-please": "catalog:deps", "h3": "catalog:deps", + "storybook": "catalog:storybook", "tsdown": "catalog:build", "unocss": "catalog:frontend", "vite": "catalog:build", diff --git a/plugins/og/src/spa/app/components/MetadataTable.stories.ts b/plugins/og/src/spa/app/components/MetadataTable.stories.ts new file mode 100644 index 00000000..d2c16148 --- /dev/null +++ b/plugins/og/src/spa/app/components/MetadataTable.stories.ts @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fullTags, sparseTags } from './_fixtures' +import MetadataTable from './MetadataTable.vue' + +const meta = { + title: 'OpenGraph/MetadataTable', + component: MetadataTable, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Full: Story = { + args: { tags: fullTags }, +} + +export const Sparse: Story = { + args: { tags: sparseTags }, +} + +export const Empty: Story = { + args: { tags: [] }, +} diff --git a/plugins/og/src/spa/app/components/MissingTags.stories.ts b/plugins/og/src/spa/app/components/MissingTags.stories.ts new file mode 100644 index 00000000..76b5d92f --- /dev/null +++ b/plugins/og/src/spa/app/components/MissingTags.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fullTags, sparseTags } from './_fixtures' +import MissingTags from './MissingTags.vue' + +const meta = { + title: 'OpenGraph/MissingTags', + component: MissingTags, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ManyMissing: Story = { + args: { tags: sparseTags }, +} + +export const NoneMissing: Story = { + args: { tags: fullTags }, +} diff --git a/plugins/og/src/spa/app/components/SocialPreview.stories.ts b/plugins/og/src/spa/app/components/SocialPreview.stories.ts new file mode 100644 index 00000000..de02d7fa --- /dev/null +++ b/plugins/og/src/spa/app/components/SocialPreview.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { fullSnapshot, sparseSnapshot } from './_fixtures' +import SocialPreview from './SocialPreview.vue' + +const meta = { + title: 'OpenGraph/SocialPreview', + component: SocialPreview, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const RichCard: Story = { + args: { snapshot: fullSnapshot }, +} + +export const Sparse: Story = { + args: { snapshot: sparseSnapshot }, +} diff --git a/plugins/og/src/spa/app/components/ViewerToolbar.stories.ts b/plugins/og/src/spa/app/components/ViewerToolbar.stories.ts new file mode 100644 index 00000000..9e3000e9 --- /dev/null +++ b/plugins/og/src/spa/app/components/ViewerToolbar.stories.ts @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { ref } from 'vue' +import ViewerToolbar from './ViewerToolbar.vue' + +const meta = { + title: 'OpenGraph/ViewerToolbar', + component: ViewerToolbar, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Idle: Story = { + render: () => ({ + components: { ViewerToolbar }, + setup() { + const target = ref('https://devfra.me/') + return { target } + }, + template: ``, + }), +} + +export const Loading: Story = { + render: () => ({ + components: { ViewerToolbar }, + setup() { + const target = ref('https://devfra.me/') + return { target } + }, + template: ``, + }), +} + +export const Static: Story = { + render: () => ({ + components: { ViewerToolbar }, + setup() { + const target = ref('https://devfra.me/') + return { target } + }, + template: ``, + }), +} diff --git a/plugins/og/src/spa/app/components/_fixtures.ts b/plugins/og/src/spa/app/components/_fixtures.ts new file mode 100644 index 00000000..18074582 --- /dev/null +++ b/plugins/og/src/spa/app/components/_fixtures.ts @@ -0,0 +1,40 @@ +import type { OgHeadTag, OgSnapshot } from '../../../types' + +// A static, fully-populated Open Graph snapshot so the presentational +// components render in isolation without a live fetch. Image URLs are +// illustrative — they resolve to the broken-image placeholder offline, which +// is itself a useful preview state. + +export const fullTags: OgHeadTag[] = [ + { tag: 'title', name: 'title', value: 'Devframe — the container for one devtool integration' }, + { tag: 'meta', name: 'description', value: 'Build a single devtool — its RPC, SPA, diagnostics — portable across viewers.' }, + { tag: 'html', name: 'lang', value: 'en' }, + { tag: 'link', name: 'icon', value: 'https://devfra.me/favicon.svg' }, + { tag: 'meta', name: 'og:title', value: 'Devframe' }, + { tag: 'meta', name: 'og:description', value: 'The framework-neutral container for one devtool integration.' }, + { tag: 'meta', name: 'og:image', value: 'https://devfra.me/og.png' }, + { tag: 'meta', name: 'og:url', value: 'https://devfra.me/' }, + { tag: 'meta', name: 'twitter:card', value: 'summary_large_image' }, + { tag: 'meta', name: 'twitter:title', value: 'Devframe' }, + { tag: 'meta', name: 'twitter:description', value: 'One devtool, portable to many viewers.' }, + { tag: 'meta', name: 'twitter:image', value: 'https://devfra.me/og.png' }, +] + +/** A snapshot missing every Open Graph / Twitter tag — exercises MissingTags. */ +export const sparseTags: OgHeadTag[] = [ + { tag: 'title', name: 'title', value: 'Untitled page' }, + { tag: 'meta', name: 'description', value: 'A page with only the basics filled in.' }, +] + +export function snapshotFrom(tags: OgHeadTag[]): OgSnapshot { + return { + requestedUrl: 'https://devfra.me/', + url: 'https://devfra.me/', + status: 200, + fetchedAt: Date.now(), + tags, + } +} + +export const fullSnapshot: OgSnapshot = snapshotFrom(fullTags) +export const sparseSnapshot: OgSnapshot = snapshotFrom(sparseTags) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a72ad63..554ea544 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,9 +263,6 @@ catalogs: '@storybook/addon-docs': specifier: ^10.5.3 version: 10.5.3 - '@storybook/html-vite': - specifier: ^10.5.3 - version: 10.5.3 '@storybook/react-vite': specifier: ^10.5.3 version: 10.5.3 @@ -728,46 +725,6 @@ importers: specifier: catalog:deps version: 8.21.1 - examples/storybook-hub: - dependencies: - '@antfu/design': - specifier: catalog:frontend - version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) - '@devframes/hub': - specifier: workspace:* - version: link:../../packages/hub - '@devframes/plugin-terminals': - specifier: workspace:* - version: link:../../plugins/terminals - colorjs.io: - specifier: catalog:frontend - version: 0.7.0 - devframe: - specifier: workspace:* - version: link:../../packages/devframe - iframe-pane: - specifier: catalog:frontend - version: 1.1.0 - devDependencies: - '@iconify-json/ph': - specifier: catalog:frontend - version: 1.2.2 - get-port-please: - specifier: catalog:deps - version: 3.2.0 - pathe: - specifier: catalog:deps - version: 2.0.3 - storybook: - specifier: catalog:storybook - version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - unocss: - specifier: catalog:frontend - version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) - vite: - specifier: catalog:build - version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) - examples/streaming-chat: dependencies: '@antfu/design': @@ -1159,6 +1116,12 @@ importers: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 + '@storybook/addon-docs': + specifier: catalog:storybook + version: 10.5.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + '@storybook/vue3-vite': + specifier: catalog:storybook + version: 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) '@types/codemirror': specifier: catalog:types version: 5.60.17 @@ -1180,6 +1143,9 @@ importers: splitpanes: specifier: catalog:frontend version: 4.1.2(vue@3.5.40(typescript@6.0.3)) + storybook: + specifier: catalog:storybook + version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tsdown: specifier: catalog:build version: 0.22.14(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -1463,6 +1429,12 @@ importers: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 + '@storybook/addon-docs': + specifier: catalog:storybook + version: 10.5.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + '@storybook/vue3-vite': + specifier: catalog:storybook + version: 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) '@types/node': specifier: catalog:types version: 26.1.1 @@ -1478,6 +1450,9 @@ importers: h3: specifier: catalog:deps version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) + storybook: + specifier: catalog:storybook + version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tsdown: specifier: catalog:build version: 0.22.14(@volar/typescript@2.4.28)(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) @@ -1577,16 +1552,35 @@ importers: version: 8.21.1 storybook: - devDependencies: + dependencies: '@antfu/design': specifier: catalog:frontend - version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@devframes/hub': + specifier: workspace:* + version: link:../packages/hub + '@devframes/plugin-terminals': + specifier: workspace:* + version: link:../plugins/terminals + colorjs.io: + specifier: catalog:frontend + version: 0.7.0 + devframe: + specifier: workspace:* + version: link:../packages/devframe + iframe-pane: + specifier: catalog:frontend + version: 1.1.0 + devDependencies: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 - '@storybook/html-vite': - specifier: catalog:storybook - version: 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + pathe: + specifier: catalog:deps + version: 2.0.3 storybook: specifier: catalog:storybook version: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -4358,17 +4352,6 @@ packages: '@storybook/global@5.0.0': resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} - '@storybook/html-vite@10.5.3': - resolution: {integrity: sha512-+nbkMtISF4HdY/ucC2Ni1EspXhUA7FQicobZ0ItJzRPMhOLm9F5zFoHaTmkC95FMqcOY0YnF7uRnFgtYFf8plg==} - peerDependencies: - storybook: ^10.5.3 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@storybook/html@10.5.3': - resolution: {integrity: sha512-YKv7IvRyTLNTfyvo96nBVwLCFe3ZT41VncErC7D84WHMos5E3Ig10o58Gyxa/ozXwjlbUvW92FZ99nTMQ6NEIw==} - peerDependencies: - storybook: ^10.5.3 - '@storybook/icons@2.0.2': resolution: {integrity: sha512-KZBCpXsshAIjczYNXR/rlxEtCUX/eAbpFNwKi8bcOomrLA4t/SyPz5RF+lVPO2oZBUE4sAkt43mfJUevQDSEEw==} peerDependencies: @@ -12327,23 +12310,6 @@ snapshots: '@storybook/global@5.0.0': {} - '@storybook/html-vite@10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': - dependencies: - '@storybook/builder-vite': 10.5.3(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) - '@storybook/html': 10.5.3(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) - storybook: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) - transitivePeerDependencies: - - esbuild - - rollup - - webpack - - '@storybook/html@10.5.3(storybook@10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))': - dependencies: - '@storybook/global': 5.0.0 - storybook: 10.5.3(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - ts-dedent: 2.2.0 - '@storybook/icons@2.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bedfd28b..ae3480c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,6 @@ overrides: crossws: ^0.4.10 semver: ^7.8.5 shell-quote: ^1.10.0 -catalog: catalogs: build: @@ -134,7 +133,6 @@ catalogs: storybook: '@storybook/addon-a11y': ^10.5.3 '@storybook/addon-docs': ^10.5.3 - '@storybook/html-vite': ^10.5.3 '@storybook/react-vite': ^10.5.3 '@storybook/svelte-vite': ^10.5.3 '@storybook/vue3-vite': ^10.5.3 diff --git a/storybook/.storybook/main.ts b/storybook/.storybook/main.ts deleted file mode 100644 index 03c04692..00000000 --- a/storybook/.storybook/main.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { StorybookConfig } from '@storybook/html-vite' -import UnoCSS from 'unocss/vite' - -// Each devframe plugin ships its own framework-specific Storybook (React, Vue, -// Svelte, Solid, vanilla). This host is a thin shell that composes them into one -// UI via Storybook refs — every plugin becomes its own top-level section. -// -// - In DEVELOPMENT each plugin runs its own dev server on a fixed port and the -// host references it live (`pnpm storybook` starts all of them in parallel). -// - In PRODUCTION `scripts/build.mjs` builds each plugin's Storybook into a -// subfolder of this host's static output, so the refs resolve on one origin. -const sections = [ - { id: 'git', title: 'Git', port: 6011 }, - { id: 'inspect', title: 'Inspect', port: 6012 }, - { id: 'code-server', title: 'Code Server', port: 6013 }, - { id: 'terminals', title: 'Terminals', port: 6014 }, - { id: 'a11y', title: 'A11y', port: 6015 }, -] - -const config: StorybookConfig = { - stories: ['../src/**/*.stories.@(ts|tsx|mdx)'], - framework: { - name: '@storybook/html-vite', - options: {}, - }, - refs: (_config, { configType }) => Object.fromEntries( - sections.map(({ id, title, port }) => [ - id, - { title, url: configType === 'DEVELOPMENT' ? `http://localhost:${port}` : `./${id}` }, - ]), - ), - viteFinal(viteConfig) { - viteConfig.plugins ??= [] - viteConfig.plugins.push(UnoCSS()) - // Dev tool reached from arbitrary hostnames (LAN IPs, tunnels, tailnets). - viteConfig.server = { ...viteConfig.server, allowedHosts: true } - return viteConfig - }, -} - -export default config diff --git a/storybook/README.md b/storybook/README.md new file mode 100644 index 00000000..e3265932 --- /dev/null +++ b/storybook/README.md @@ -0,0 +1,58 @@ +# @devframes/storybook + +The unified Storybook host, built as a **devframe hub** on `@devframes/hub`: the +hub is the shell and each built-in plugin's Storybook is its own dock, alongside +the live terminals plugin running as a real integration. + +## How it works + +The whole host is one Vite plugin (`src/hub.ts`): it creates a hub context, +implements the framework-neutral `DevframeHost`, registers a launcher dock (and +a bound command) per plugin Storybook, mounts the terminals plugin via +`mountDevframe`, and starts a side-car RPC/WS server. + +Each Storybook dock is a `type: 'launcher'` tile with a **Start** button — the +lazy trigger. The button binds a `ctx.commands` command (`storybook:launch:`), +so the client dispatches it over the serializable `hub:commands:execute` path. +Once launched, the tile swaps in place for the running Storybook's iframe, kept +mounted so its state survives tab switches. Where the iframe points depends on +the mode: + +- **dev** (`vite`) — the launch command spawns the plugin's `storybook dev` + through `ctx.terminals`, the hub's terminals subsystem, so each Storybook is a + read-only terminal session (open the **Terminals** dock to watch its output + stream live). As it boots, the tail of that output is patched onto the + launcher's `digest`; on ready the command returns the live dev-server URL the + client iframes (HMR). +- **build** (`vite preview`) — the launch resolves immediately to the pre-built + `storybook-static/` the hub serves on one origin. + +## Run it + +Build the plugin SPAs the hub mounts (terminals) once: + +```sh +pnpm build +``` + +### Dev — Storybooks spawned on demand + +```sh +pnpm storybook +``` + +Open the printed URL, pick a Storybook in the sidebar, and hit **Start**; its +dev server boots on demand (subsequent opens are instant). The dev servers +listen on their own ports, so reaching them from a remote browser needs those +ports forwarded. + +### Preview — pre-built Storybooks on one origin + +```sh +pnpm storybook:build # produces storybook-static/ +pnpm --filter @devframes/storybook build # builds the hub UI → dist/ +pnpm --filter @devframes/storybook preview +``` + +Everything is served from the single preview origin, so one forwarded port +reaches the whole hub. diff --git a/examples/storybook-hub/index.html b/storybook/index.html similarity index 95% rename from examples/storybook-hub/index.html rename to storybook/index.html index e189b0ae..414f432c 100644 --- a/examples/storybook-hub/index.html +++ b/storybook/index.html @@ -3,7 +3,7 @@ - Storybook Hub + devframe · Storybook