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);
+});