From e9a4d3a996dabf2d3329086bc13a450c3dc4061a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 19:41:59 +0200 Subject: [PATCH] feat(ui): storybook 10 over the real pages + screenshot harness Preview imports the mock transport statically and wraps every story in a fresh RegistryProvider seeded with mock mode + the story's scenario parameter (dark default, light via toolbar). One story file per page (Default + FirstRun/Syncing/Errors where meaningful) plus the whole shell. tools/screenshots.mjs mirrors the console's harness: headless Chromium over storybook-static, every Pages/* + Shell/* story. Co-Authored-By: Claude Fable 5 --- ui/.storybook/main.ts | 12 +++ ui/.storybook/preview.tsx | 65 +++++++++++++ ui/src/App.stories.tsx | 14 +++ ui/src/pages/emulators.stories.tsx | 14 +++ ui/src/pages/library.stories.tsx | 16 ++++ ui/src/pages/overview.stories.tsx | 16 ++++ ui/src/pages/settings.stories.tsx | 13 +++ ui/src/pages/sources.stories.tsx | 14 +++ ui/tools/screenshots.mjs | 144 +++++++++++++++++++++++++++++ 9 files changed, 308 insertions(+) create mode 100644 ui/.storybook/main.ts create mode 100644 ui/.storybook/preview.tsx create mode 100644 ui/src/App.stories.tsx create mode 100644 ui/src/pages/emulators.stories.tsx create mode 100644 ui/src/pages/library.stories.tsx create mode 100644 ui/src/pages/overview.stories.tsx create mode 100644 ui/src/pages/settings.stories.tsx create mode 100644 ui/src/pages/sources.stories.tsx create mode 100644 ui/tools/screenshots.mjs diff --git a/ui/.storybook/main.ts b/ui/.storybook/main.ts new file mode 100644 index 0000000..b5d49d3 --- /dev/null +++ b/ui/.storybook/main.ts @@ -0,0 +1,12 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.tsx"], + addons: [], + framework: { + name: "@storybook/react-vite", + options: {}, + }, +}; + +export default config; diff --git a/ui/.storybook/preview.tsx b/ui/.storybook/preview.tsx new file mode 100644 index 0000000..b4a0a23 --- /dev/null +++ b/ui/.storybook/preview.tsx @@ -0,0 +1,65 @@ +// Storybook renders the REAL pages on the mock transport: the static import below +// registers the fixture HttpClient layer + status ticker (Storybook bundles are never +// shipped, so fixtures in them are fine), and the decorator gives every story a fresh +// atom registry seeded with mock mode + the story's scenario. +import "../src/styles.css"; +import "../src/mocks/http"; +import { RegistryProvider } from "@effect/atom-react"; +import { definePreview } from "@storybook/react-vite"; +import { useEffect } from "react"; +import { + apiModeAtom, + type Scenario, + scenarioAtom, +} from "../src/mocks/scenario"; + +export default definePreview({ + addons: [], + // The console pins dark; default the canvas to dark with a toolbar light switch. + initialGlobals: { theme: "dark" }, + globalTypes: { + theme: { + description: "Light/dark color scheme", + toolbar: { + title: "Theme", + icon: "circlehollow", + items: [ + { value: "dark", icon: "moon", title: "Dark" }, + { value: "light", icon: "sun", title: "Light" }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [ + (Story, context) => { + const dark = (context.globals.theme as string) !== "light"; + const scenario = + (context.parameters.scenario as Scenario | undefined) ?? "healthy"; + const fullscreen = context.parameters.layout === "fullscreen"; + // Mirror `.dark` onto so portal-mounted content (selects, dialogs, + // toasts) picks up the palette — the theme keys everything off `html.dark`. + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + }, [dark]); + return ( + +
+ +
+
+ ); + }, + ], + parameters: { + layout: "padded", + }, +}); diff --git a/ui/src/App.stories.tsx b/ui/src/App.stories.tsx new file mode 100644 index 0000000..eab7eee --- /dev/null +++ b/ui/src/App.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { App } from "./App"; + +const meta = { + title: "Shell/App", + component: App, + parameters: { layout: "fullscreen" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; diff --git a/ui/src/pages/emulators.stories.tsx b/ui/src/pages/emulators.stories.tsx new file mode 100644 index 0000000..ec4b081 --- /dev/null +++ b/ui/src/pages/emulators.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmulatorsPage } from "./emulators"; + +const meta = { + title: "Pages/Emulators", + component: EmulatorsPage, + args: { navigate: () => {} }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; diff --git a/ui/src/pages/library.stories.tsx b/ui/src/pages/library.stories.tsx new file mode 100644 index 0000000..2aaa17a --- /dev/null +++ b/ui/src/pages/library.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LibraryPage } from "./library"; + +const meta = { + title: "Pages/Library", + component: LibraryPage, + args: { navigate: () => {} }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; +/** The preview endpoint fails (ScanFailed 500) — exercises the error gate. */ +export const Errors: Story = { parameters: { scenario: "errors" } }; diff --git a/ui/src/pages/overview.stories.tsx b/ui/src/pages/overview.stories.tsx new file mode 100644 index 0000000..7765031 --- /dev/null +++ b/ui/src/pages/overview.stories.tsx @@ -0,0 +1,16 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewPage } from "./overview"; + +const meta = { + title: "Pages/Overview", + component: OverviewPage, + args: { navigate: () => {} }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; +export const Syncing: Story = { parameters: { scenario: "syncing" } }; +export const Errors: Story = { parameters: { scenario: "errors" } }; diff --git a/ui/src/pages/settings.stories.tsx b/ui/src/pages/settings.stories.tsx new file mode 100644 index 0000000..002957b --- /dev/null +++ b/ui/src/pages/settings.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsPage } from "./settings"; + +const meta = { + title: "Pages/Settings", + component: SettingsPage, + args: { navigate: () => {} }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/ui/src/pages/sources.stories.tsx b/ui/src/pages/sources.stories.tsx new file mode 100644 index 0000000..6cc901a --- /dev/null +++ b/ui/src/pages/sources.stories.tsx @@ -0,0 +1,14 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SourcesPage } from "./sources"; + +const meta = { + title: "Pages/Sources", + component: SourcesPage, + args: { navigate: () => {} }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; diff --git a/ui/tools/screenshots.mjs b/ui/tools/screenshots.mjs new file mode 100644 index 0000000..aae123f --- /dev/null +++ b/ui/tools/screenshots.mjs @@ -0,0 +1,144 @@ +// Capture page screenshots from the built Storybook — the same harness the console +// uses (punktfunk/web/tools/screenshots.mjs): headless Chromium over storybook-static, +// every Pages/* + Shell/* story, rendered entirely from fixtures. No host required. +// +// bun run build-storybook # produce ./storybook-static +// bun run screenshots # → ./screenshots/.png +// +// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the +// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x), +// ONLY (comma-separated story-id substring filter). + +import { existsSync } from "node:fs"; +import { mkdir, readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { extname, join, normalize, resolve } from "node:path"; +import { chromium } from "playwright"; + +const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static"); +const OUT = resolve(process.env.OUT ?? "screenshots"); +const SETTLE = Number(process.env.SETTLE ?? 600); +const WIDTH = Number(process.env.WIDTH ?? 1440); +const HEIGHT = Number(process.env.HEIGHT ?? 900); +const SCALE = Number(process.env.SCALE ?? 2); +const ONLY = (process.env.ONLY ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +// Only the page-level + shell stories make sense as screenshots. +const TITLE_PREFIXES = ["Pages/", "Shell/"]; + +const MIME = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json", + ".ico": "image/x-icon", +}; + +function staticServer(rootDir) { + return createServer(async (req, res) => { + try { + const url = new URL(req.url, "http://localhost"); + let path = decodeURIComponent(url.pathname); + if (path.endsWith("/")) path += "index.html"; + // Contain the path to rootDir (no traversal). + const filePath = normalize(join(rootDir, path)); + if (!filePath.startsWith(rootDir)) { + res.writeHead(403).end(); + return; + } + const body = await readFile(filePath); + res.writeHead(200, { + "content-type": MIME[extname(filePath)] ?? "application/octet-stream", + }); + res.end(body); + } catch { + res.writeHead(404).end(); + } + }); +} + +async function listStories(rootDir) { + const indexPath = join(rootDir, "index.json"); + if (!existsSync(indexPath)) { + throw new Error( + `${indexPath} not found — run \`bun run build-storybook\` first`, + ); + } + const index = JSON.parse(await readFile(indexPath, "utf8")); + const entries = Object.values(index.entries ?? index.stories ?? {}); + return entries + .filter((e) => e.type === "story" || e.type === undefined) + .filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p))) + .filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f))) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +async function main() { + if (!existsSync(ROOT)) { + throw new Error( + `${ROOT} not found — run \`bun run build-storybook\` first`, + ); + } + const stories = await listStories(ROOT); + if (stories.length === 0) + throw new Error("no Pages/* or Shell/* stories found"); + await mkdir(OUT, { recursive: true }); + + const server = staticServer(ROOT); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + + const browser = await chromium.launch({ + args: ["--force-color-profile=srgb"], + }); + const context = await browser.newContext({ + viewport: { width: WIDTH, height: HEIGHT }, + deviceScaleFactor: SCALE, + colorScheme: "dark", + }); + + let ok = 0; + for (const story of stories) { + const page = await context.newPage(); + const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent( + story.id, + )}&viewMode=story`; + try { + await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 }); + // Story root mounted with real content. + await page.waitForSelector("#storybook-root > *", { timeout: 20_000 }); + // Web fonts settled (else text reflows / falls back in the shot). + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(SETTLE); + const file = join(OUT, `${story.id}.png`); + await page.screenshot({ path: file }); + console.log(`ok ${story.id} -> ${file}`); + ok++; + } catch (e) { + console.warn(`FAIL ${story.id}: ${e.message}`); + } finally { + await page.close(); + } + } + + await browser.close(); + await new Promise((r) => server.close(r)); + console.log(`\n${ok}/${stories.length} stories captured -> ${OUT}`); + if (ok === 0) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +});