basis-design 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ To work on Basis itself, clone the repository and run `npm install` and `npm run
37
37
 
38
38
  Each project is a workspace of its own: its own design system, history, chat and review. Switch, create and open projects from the menu with the project's name at the top left.
39
39
 
40
- A project can link the **product repo** it designs for (project menu, **Link product repo…**). The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
40
+ A project can link the **product repo** it designs for (project menu, **Link product repo…**): pick it from the repos Basis finds in your usual code folders, choose it with **Browse…** in your system's folder dialog, or paste its path. The agent reads and searches it without asking, to match the product's look, components and terms, and never writes there: writes outside the workspace are refused. **New project…** can also start from a product repo: the project takes the repo folder's name unless you give one, links the repo and starts by bringing in its design. **Bring in its design** (offered when you link, and in the project menu) starts a guided turn: the agent maps the repo's tokens onto `design-system.json`, restyles the components and patterns to resemble the product's, writes what it learned to `product.md` (where the tokens live, an inventory of the product's components and their Basis counterparts, terms to reuse) and opens a review.
41
41
 
42
42
  - **New project** starts from Basis' defaults, or as a copy of the open project's tokens and components (not its history). It lives in `~/.basis/workspaces/`.
43
43
  - **Open folder** adds a workspace from anywhere, or an empty or new folder for Basis to set up, inside a product repo for instance. Basis refuses a folder that already has other files, so it never starts a history in someone's project.
@@ -107,6 +107,7 @@ Every `components/*.specimen.tsx` file becomes a gallery section and a sidebar e
107
107
  - a README on wiring it up.
108
108
 
109
109
  The kit needs React 18+ and CSS Modules, nothing else. The other tabs export the token formats one at a time.
110
+ - **Verification kit:** proof that a feature was built as its view shows. The **Verification kit** tab photographs every screen of every view at fixed widths (375, 768 and 1440 pixels, full page, unless the view's flow block sets `capture`) and packages the screenshots with a static, clickable copy of the design and two scripts, as a zip or into `basis-verify/` in the product's folder (the linked repo by default). The agent that implements the feature runs `capture.mjs` to photograph the design and its implementation on the same machine with the same settings, and `compare.mjs` to compare them pixel for pixel, aiming at 0% difference. Parts allowed to differ are named exceptions in `exceptions.json`, each with a reason. Basis takes the screenshots with Playwright's Chromium when it is installed (`npx playwright install chromium`), otherwise with Chrome or Edge; each screen is captured twice, and screens that render differently each time are reported, since nothing can match them.
110
111
 
111
112
  ## Privacy
112
113
 
package/capture.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Basis capture</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/capture/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json CHANGED
@@ -1,70 +1,72 @@
1
- {
2
- "name": "basis-design",
3
- "version": "0.1.1",
4
- "description": "Prompt a complete design system into existence with your own coding agent over ACP.",
5
- "keywords": [
6
- "design-system",
7
- "design-tokens",
8
- "react",
9
- "claude-code",
10
- "codex",
11
- "agent-client-protocol"
12
- ],
13
- "license": "Apache-2.0",
14
- "author": "Lucas Skog",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/sockulags/basis-design.git"
18
- },
19
- "type": "module",
20
- "bin": {
21
- "basis": "bin/basis.js"
22
- },
23
- "files": [
24
- "bin",
25
- "server",
26
- "shared",
27
- "src",
28
- "template",
29
- "index.html",
30
- "preview.html",
31
- "vite.config.ts",
32
- "NOTICE"
33
- ],
34
- "engines": {
35
- "node": ">=22.12.0"
36
- },
37
- "scripts": {
38
- "dev": "tsx server/index.ts",
39
- "start": "tsx server/index.ts",
40
- "typecheck": "tsc --noEmit",
41
- "test": "node --import tsx --test \"test/*.test.ts\"",
42
- "test:e2e": "playwright test",
43
- "template:history": "tsx scripts/template-history.ts",
44
- "lint": "oxlint",
45
- "format": "prettier --write .",
46
- "check": "tsc --noEmit && oxlint && prettier --check . && npm test"
47
- },
48
- "dependencies": {
49
- "@agentclientprotocol/sdk": "^1.9.0",
50
- "@types/react": "^19.3.0",
51
- "@types/react-dom": "^19.3.0",
52
- "@vitejs/plugin-react": "^6.1.2",
53
- "axe-core": "^4.14.0",
54
- "fflate": "^0.8.3",
55
- "react": "^19.3.1",
56
- "react-dom": "^19.3.1",
57
- "tsx": "^4.23.15",
58
- "typescript": "^7.0.2",
59
- "vite": "^8.3.4",
60
- "ws": "^8.22.0",
61
- "zod": "^4.6.7"
62
- },
63
- "devDependencies": {
64
- "@playwright/test": "^1.64.1",
65
- "@types/node": "^26.6.5",
66
- "@types/ws": "^8.18.2",
67
- "oxlint": "1.88.0",
68
- "prettier": "3.9.10"
69
- }
70
- }
1
+ {
2
+ "name": "basis-design",
3
+ "version": "0.2.0",
4
+ "description": "Prompt a complete design system into existence with your own coding agent over ACP.",
5
+ "keywords": [
6
+ "design-system",
7
+ "design-tokens",
8
+ "react",
9
+ "claude-code",
10
+ "codex",
11
+ "agent-client-protocol"
12
+ ],
13
+ "license": "Apache-2.0",
14
+ "author": "Lucas Skog",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/sockulags/basis-design.git"
18
+ },
19
+ "type": "module",
20
+ "bin": {
21
+ "basis": "bin/basis.js"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "server",
26
+ "shared",
27
+ "src",
28
+ "template",
29
+ "index.html",
30
+ "preview.html",
31
+ "capture.html",
32
+ "vite.config.ts",
33
+ "NOTICE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22.12.0"
37
+ },
38
+ "scripts": {
39
+ "dev": "tsx server/index.ts",
40
+ "start": "tsx server/index.ts",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "node --import tsx --test \"test/*.test.ts\"",
43
+ "test:e2e": "playwright test",
44
+ "template:history": "tsx scripts/template-history.ts",
45
+ "lint": "oxlint",
46
+ "format": "prettier --write .",
47
+ "check": "tsc --noEmit && oxlint && prettier --check . && npm test"
48
+ },
49
+ "dependencies": {
50
+ "@agentclientprotocol/sdk": "^1.9.0",
51
+ "@types/react": "^19.3.0",
52
+ "@types/react-dom": "^19.3.0",
53
+ "@vitejs/plugin-react": "^6.1.2",
54
+ "axe-core": "^4.14.0",
55
+ "fflate": "^0.8.3",
56
+ "playwright-core": "1.64.1",
57
+ "react": "^19.3.1",
58
+ "react-dom": "^19.3.1",
59
+ "tsx": "^4.23.15",
60
+ "typescript": "^7.0.2",
61
+ "vite": "^8.3.4",
62
+ "ws": "^8.22.0",
63
+ "zod": "^4.6.7"
64
+ },
65
+ "devDependencies": {
66
+ "@playwright/test": "^1.64.1",
67
+ "@types/node": "^26.6.5",
68
+ "@types/ws": "^8.18.2",
69
+ "oxlint": "1.88.0",
70
+ "prettier": "3.9.10"
71
+ }
72
+ }
package/server/brief.ts CHANGED
@@ -160,9 +160,20 @@ The rules that always apply:
160
160
  restyle or copy them inside a view: if a screen needs something they lack, extend the component or pattern,
161
161
  or add a pattern. A view's own code is layout and wiring; a small layout style inline or in a
162
162
  \`.module.css\` next to it is fine.
163
- - Fill views with realistic made-up data, consistent across the screens (the row you open is the one the
164
- detail shows), in the product's language when a product repo is linked. Keep sample data in the view file or
165
- a \`views/*-data.ts\` file.
163
+ - A screen is the whole page as the product shows it at that size: its shell, navigation and page padding
164
+ included, filling the window. The verification kit photographs each screen alone at fixed widths, and the
165
+ implementation of the feature must match those images pixel for pixel.
166
+ - Fill views with realistic data, consistent across the screens (the row you open is the one the detail
167
+ shows). When a product repo is linked, use the product's own data where you can find it (fixtures, seeds,
168
+ example responses, its wording), so the implementation can show exactly the same records. Otherwise make it
169
+ up in the product's language. Keep data in the view file or a \`views/*-data.ts\` file.
170
+ - Screens must render the same every time: no \`Date.now()\`, \`new Date()\` without a fixed date, random
171
+ values, or animations and loading states that never settle. Basis captures each screen twice and reports
172
+ the ones that differ.
173
+ - A flow block may say how its screens are captured: \`capture: { widths: [375, 768, 1440], height: "full",
174
+ themes: ["light"] }\` (those are the defaults). \`height\` is \`"full"\` for the whole page or a number for
175
+ exactly one viewport; add \`"dark"\` to \`themes\` when the product has a dark theme. Set widths to the
176
+ product's breakpoints when you know them.
166
177
  - Views are not exported with the design system and need no contract or index.
167
178
  - To review a view, show its specimen; \`rows\` picks screens by label or id (\`"rows": ["Shipment"]\`). The panes
168
179
  of a review move through the flow together, so before and after always show the same screen.
@@ -0,0 +1,128 @@
1
+ import { chromium, type Browser, type LaunchOptions } from "playwright-core";
2
+ import { READY_ATTRIBUTE, shotFile, type CaptureShot, type CaptureView } from "../shared/capture.ts";
3
+
4
+ /*
5
+ * Taking the verification kit's screenshots: a headless browser opens each screen of each view on the capture
6
+ * page (capture.html) at the size its shots ask for. Everything that could make two runs differ is fixed: the
7
+ * device scale, the colour profile, text antialiasing, reduced motion. Each shot is taken twice, and a screen
8
+ * whose two images differ is reported as unstable, since nothing could ever match it pixel for pixel.
9
+ */
10
+
11
+ /** Chromium flags that keep rendering the same between runs and machines of one platform. */
12
+ export const CAPTURE_ARGS = [
13
+ "--force-color-profile=srgb",
14
+ "--font-render-hinting=none",
15
+ "--disable-lcd-text",
16
+ "--hide-scrollbars",
17
+ ];
18
+
19
+ export interface CaptureBrowser {
20
+ browser: Browser;
21
+ name: string;
22
+ version: string;
23
+ }
24
+
25
+ /**
26
+ * Playwright's own Chromium when it is installed (`npx playwright install chromium`): the same build on every
27
+ * machine, which is what comparing screenshots wants. Otherwise the Chrome or Edge already on this computer.
28
+ */
29
+ export async function launchCaptureBrowser(): Promise<CaptureBrowser> {
30
+ const candidates: { name: string; options: LaunchOptions }[] = [
31
+ { name: "Playwright Chromium", options: {} },
32
+ { name: "Google Chrome", options: { channel: "chrome" } },
33
+ { name: "Microsoft Edge", options: { channel: "msedge" } },
34
+ ];
35
+ for (const { name, options } of candidates) {
36
+ try {
37
+ const browser = await chromium.launch({ ...options, headless: true, args: CAPTURE_ARGS });
38
+ return { browser, name, version: browser.version() };
39
+ } catch {
40
+ // Not installed; try the next one.
41
+ }
42
+ }
43
+ throw new Error(
44
+ "No browser to take the screenshots with. Install Google Chrome or Microsoft Edge, or run `npx playwright install chromium`.",
45
+ );
46
+ }
47
+
48
+ export interface Shot extends CaptureShot {
49
+ view: string;
50
+ screen: string;
51
+ file: string;
52
+ png: Buffer;
53
+ /** The image's height in pixels: the page's height for full-page shots. */
54
+ imageHeight: number;
55
+ /** False when two captures of the shot differed, so the screen does not render the same every time. */
56
+ stable: boolean;
57
+ /** Set when the screen threw while rendering. */
58
+ failed: boolean;
59
+ }
60
+
61
+ export interface CaptureResult {
62
+ views: CaptureView[];
63
+ problems: string[];
64
+ shots: Shot[];
65
+ }
66
+
67
+ /** The height stored in a PNG's IHDR chunk. */
68
+ function pngHeight(png: Buffer): number {
69
+ return png.length >= 24 ? png.readUInt32BE(20) : 0;
70
+ }
71
+
72
+ /**
73
+ * Captures every shot of every view the capture page at `pageUrl` lists. `pageUrl` is the page without a query
74
+ * (".../capture.html" on the preview server, ".../index.html" in the kit's copy of the design).
75
+ */
76
+ export async function captureViews(
77
+ pageUrl: string,
78
+ browser: Browser,
79
+ onProgress?: (done: number, total: number) => void,
80
+ ): Promise<CaptureResult> {
81
+ const context = await browser.newContext({
82
+ deviceScaleFactor: 1,
83
+ reducedMotion: "reduce",
84
+ colorScheme: "light",
85
+ viewport: { width: 1440, height: 900 },
86
+ });
87
+ try {
88
+ const page = await context.newPage();
89
+ await page.goto(`${pageUrl}?list`);
90
+ await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
91
+ const listed = await page.evaluate(() => window.__basisViews);
92
+ const views = listed?.views ?? [];
93
+ const problems = listed?.problems ?? [];
94
+ const total = views.reduce((sum, view) => sum + view.screens.reduce((count, s) => count + s.shots.length, 0), 0);
95
+ const shots: Shot[] = [];
96
+ for (const view of views) {
97
+ for (const screen of view.screens) {
98
+ for (const shot of screen.shots) {
99
+ const url = `${pageUrl}?view=${encodeURIComponent(view.id)}&screen=${encodeURIComponent(screen.id)}&theme=${shot.theme}`;
100
+ await page.setViewportSize({ width: shot.width, height: shot.viewportHeight });
101
+ const take = async () => {
102
+ await page.goto(url);
103
+ await page.waitForSelector(`html[${READY_ATTRIBUTE}]`, { state: "attached", timeout: 60_000 });
104
+ const failed = await page.evaluate(() => document.documentElement.hasAttribute("data-basis-error"));
105
+ const png = await page.screenshot({ fullPage: shot.fullPage, animations: "disabled", caret: "hide" });
106
+ return { png, failed };
107
+ };
108
+ const first = await take();
109
+ const second = await take();
110
+ shots.push({
111
+ ...shot,
112
+ view: view.id,
113
+ screen: screen.id,
114
+ file: shotFile(view.id, screen.id, shot),
115
+ png: first.png,
116
+ imageHeight: pngHeight(first.png),
117
+ stable: first.png.equals(second.png),
118
+ failed: first.failed,
119
+ });
120
+ onProgress?.(shots.length, total);
121
+ }
122
+ }
123
+ }
124
+ return { views, problems, shots };
125
+ } finally {
126
+ await context.close();
127
+ }
128
+ }
@@ -0,0 +1,125 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readdir, stat } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ /*
8
+ * Choosing folders without typing their path: the operating system's own folder dialog (Basis runs on the same
9
+ * computer as the browser), and the git repos found in the usual places for code.
10
+ */
11
+
12
+ /** The folder the user chose, or null when they cancelled. Throws when this system has no folder dialog. */
13
+ export async function pickFolder(title: string, start?: string): Promise<string | null> {
14
+ const from = start && existsSync(start) ? start : os.homedir();
15
+ // Title and start folder travel as variables, so no quoting can break the scripts.
16
+ const env = { ...process.env, BASIS_TITLE: title, BASIS_START: from };
17
+ if (process.platform === "win32") {
18
+ const shell = (await has("pwsh")) ? "pwsh" : "powershell";
19
+ return run(shell, ["-NoProfile", "-NonInteractive", "-STA", "-Command", WINDOWS_DIALOG], env);
20
+ }
21
+ if (process.platform === "darwin") {
22
+ const chosen = await run("osascript", ["-e", MAC_DIALOG], env);
23
+ return chosen && chosen.length > 1 ? chosen.replace(/\/$/, "") : chosen;
24
+ }
25
+ if (await has("zenity")) {
26
+ return run("zenity", ["--file-selection", "--directory", `--title=${title}`, `--filename=${from}/`], env);
27
+ }
28
+ if (await has("kdialog")) return run("kdialog", ["--getexistingdirectory", from, "--title", title], env);
29
+ throw new Error("This system has no folder dialog Basis can open (install zenity or kdialog), so type the path.");
30
+ }
31
+
32
+ // A hidden, topmost owner window keeps the dialog in front of the browser.
33
+ const WINDOWS_DIALOG = `
34
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
35
+ Add-Type -AssemblyName System.Windows.Forms
36
+ $owner = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false; Opacity = 0 }
37
+ $owner.Show()
38
+ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog
39
+ $dialog.Description = $env:BASIS_TITLE
40
+ $dialog.SelectedPath = $env:BASIS_START
41
+ try { $dialog.UseDescriptionForTitle = $true } catch {}
42
+ if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dialog.SelectedPath) }
43
+ $owner.Close()
44
+ `;
45
+
46
+ const MAC_DIALOG = `POSIX path of (choose folder with prompt (system attribute "BASIS_TITLE") default location (POSIX file (system attribute "BASIS_START")))`;
47
+
48
+ /** Runs a dialog: its output is the chosen path; cancelling exits non-zero with nothing printed. */
49
+ function run(command: string, args: string[], env: NodeJS.ProcessEnv): Promise<string | null> {
50
+ return new Promise((resolve, reject) => {
51
+ execFile(command, args, { env, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
52
+ const chosen = stdout.trim();
53
+ if (chosen) return resolve(chosen);
54
+ if (error && "code" in error && typeof error.code === "string") return reject(error); // Not found, not run.
55
+ resolve(null);
56
+ });
57
+ });
58
+ }
59
+
60
+ function has(command: string): Promise<boolean> {
61
+ const probe = process.platform === "win32" ? "where" : "which";
62
+ return new Promise((resolve) => execFile(probe, [command], { windowsHide: true }, (error) => resolve(!error)));
63
+ }
64
+
65
+ export interface RepoSuggestion {
66
+ name: string;
67
+ dir: string;
68
+ }
69
+
70
+ /** Folders under the home folder where people usually keep their code. */
71
+ const CODE_FOLDERS = [
72
+ "Code",
73
+ "code",
74
+ "Projects",
75
+ "projects",
76
+ "dev",
77
+ "Dev",
78
+ "Developer",
79
+ "src",
80
+ "repos",
81
+ "Repos",
82
+ "git",
83
+ "GitHub",
84
+ "workspace",
85
+ path.join("Documents", "GitHub"),
86
+ path.join("source", "repos"),
87
+ ];
88
+
89
+ /**
90
+ * Git repos in the usual places for code, up to two levels down, the most recently used first (by the git index,
91
+ * which changes with every checkout and commit). `skip` leaves out folders such as Basis' own.
92
+ */
93
+ export async function suggestRepos(home = os.homedir(), skip: string[] = [], limit = 12): Promise<RepoSuggestion[]> {
94
+ const found = new Map<string, number>();
95
+ const visit = async (dir: string, depth: number) => {
96
+ if (found.size >= 200) return;
97
+ const git = path.join(dir, ".git");
98
+ // The code folder itself is never the suggestion, even when someone made it a repo.
99
+ if (depth < 2 && existsSync(git)) {
100
+ const index = await stat(path.join(git, "index")).catch(() => stat(git));
101
+ found.set(dir, index.mtimeMs);
102
+ return;
103
+ }
104
+ if (depth === 0) return;
105
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
106
+ for (const entry of entries) {
107
+ if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
108
+ await visit(path.join(dir, entry.name), depth - 1);
109
+ }
110
+ };
111
+ // One folder once, also when a case-insensitive disk finds it as both "Code" and "code".
112
+ const roots = new Map<string, string>();
113
+ for (const folder of CODE_FOLDERS) {
114
+ const dir = path.join(home, folder);
115
+ const info = await stat(dir).catch(() => null);
116
+ if (info?.isDirectory() && !roots.has(`${info.dev}:${info.ino}`)) roots.set(`${info.dev}:${info.ino}`, dir);
117
+ }
118
+ for (const root of roots.values()) await visit(root, 2);
119
+ const skipped = skip.map((dir) => path.resolve(dir).toLowerCase());
120
+ return [...found]
121
+ .filter(([dir]) => !skipped.some((other) => path.resolve(dir).toLowerCase() === other))
122
+ .sort((a, b) => b[1] - a[1])
123
+ .slice(0, limit)
124
+ .map(([dir]) => ({ name: path.basename(dir), dir }));
125
+ }
package/server/index.ts CHANGED
@@ -15,8 +15,10 @@ import {
15
15
  } from "../shared/protocol.ts";
16
16
  import { createInstruction, importInstruction, templateInstruction, viewInstruction } from "./brief.ts";
17
17
  import { buildStarterKit, slug, writeStarterKit, zipStarterKit } from "./export.ts";
18
+ import { buildVerificationKit, writeVerificationKit, zipVerificationKit } from "./verify.ts";
18
19
  import { PreviewReports } from "./previews.ts";
19
20
  import { guard, isLocalHost, OpenProject, refuseHost, type ProjectOptions } from "./project.ts";
21
+ import { pickFolder, suggestRepos } from "./folders.ts";
20
22
  import { checkRepo, Projects } from "./projects.ts";
21
23
  import { templateChanges } from "./template.ts";
22
24
 
@@ -98,16 +100,62 @@ const chromeVite = await createVite({
98
100
  // Vite forwards browser console errors to the terminal when it thinks an agent runs it; for Basis that is noise.
99
101
  server: { middlewareMode: true, hmr: { server: chromeServer }, forwardConsole: false },
100
102
  });
101
- const chromeGuard = guard((url) => url === "/preview.html");
103
+ const chromeGuard = guard((url) => url === "/preview.html" || url === "/capture.html");
102
104
  chromeServer.on("request", (req, res) => {
103
105
  if (!isLocalHost(req, port)) return refuseHost(res);
104
106
  if (req.method === "GET" && req.url === "/export/starter-kit.zip") {
105
107
  void sendStarterKitZip(res);
106
108
  return;
107
109
  }
110
+ if (req.method === "GET" && req.url === "/export/verification-kit.zip") {
111
+ sendVerificationZip(res);
112
+ return;
113
+ }
114
+ if (req.url?.startsWith("/api/")) {
115
+ void handleApi(req, res);
116
+ return;
117
+ }
108
118
  chromeGuard(req, res, () => chromeVite.middlewares(req, res));
109
119
  });
110
120
 
121
+ /** True while a folder dialog is open: one at a time. */
122
+ let picking = false;
123
+
124
+ /**
125
+ * Small requests the chrome makes and waits for: a folder from the system's dialog, repos to suggest. Only POSTs
126
+ * from the chrome's own origin, so another web page can neither open dialogs on this computer nor read the answer.
127
+ */
128
+ async function handleApi(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
129
+ const reply = (status: number, body: unknown) => {
130
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
131
+ res.end(JSON.stringify(body));
132
+ };
133
+ if (req.method !== "POST" || !chromeOrigins.includes(req.headers.origin ?? "")) return reply(403, {});
134
+ let body: { title?: string; start?: string };
135
+ try {
136
+ let raw = "";
137
+ for await (const chunk of req) raw += chunk;
138
+ body = raw ? JSON.parse(raw) : {};
139
+ } catch {
140
+ return reply(400, {});
141
+ }
142
+ if (req.url === "/api/pick-folder") {
143
+ if (picking) return reply(409, { error: "A folder dialog is already open." });
144
+ picking = true;
145
+ try {
146
+ return reply(200, { path: await pickFolder(body.title ?? "Choose a folder", body.start) });
147
+ } catch (error) {
148
+ return reply(200, { path: null, error: (error as Error).message });
149
+ } finally {
150
+ picking = false;
151
+ }
152
+ }
153
+ if (req.url === "/api/repos") {
154
+ return reply(200, { repos: await suggestRepos(undefined, [root, current.entry.dir]) });
155
+ }
156
+ reply(404, {});
157
+ }
158
+
111
159
  async function starterKit() {
112
160
  return buildStarterKit(current.workspace.state.design, current.workspace.dir);
113
161
  }
@@ -129,6 +177,59 @@ async function sendStarterKitZip(res: http.ServerResponse): Promise<void> {
129
177
  }
130
178
  }
131
179
 
180
+ /** The last verification kit built as a zip, until it is downloaded or another is built. */
181
+ let verificationZip: { name: string; data: Uint8Array } | null = null;
182
+ /** True while a verification kit is being built: one at a time, and no project switch meanwhile. */
183
+ let verifying = false;
184
+
185
+ function sendVerificationZip(res: http.ServerResponse): void {
186
+ if (!verificationZip) {
187
+ res.writeHead(404, { "Content-Type": "text/plain" });
188
+ res.end("No verification kit has been built. Build one from the Export dialog.");
189
+ return;
190
+ }
191
+ res.writeHead(200, {
192
+ "Content-Type": "application/zip",
193
+ "Content-Disposition": `attachment; filename="${verificationZip.name}"`,
194
+ "Content-Length": verificationZip.data.byteLength,
195
+ "Cache-Control": "no-store",
196
+ });
197
+ res.end(verificationZip.data);
198
+ }
199
+
200
+ /** Builds the verification kit for the open project, reporting progress to every tab. */
201
+ async function verificationKit(project: string | undefined, socket: WebSocket): Promise<void> {
202
+ if (verifying) {
203
+ send(socket, { type: "verify-result", result: { ok: false, error: "A verification kit is already being built." } });
204
+ return;
205
+ }
206
+ verifying = true;
207
+ try {
208
+ const { files, summary } = await buildVerificationKit({
209
+ design: current.workspace.state.design,
210
+ workspaceDir: current.workspace.dir,
211
+ onProgress: (step, done, total) => broadcast({ type: "verify-progress", step, done, total }),
212
+ });
213
+ if (project === undefined) {
214
+ verificationZip = {
215
+ name: `${slug(current.workspace.state.design.name)}-verification-kit.zip`,
216
+ data: zipVerificationKit(files),
217
+ };
218
+ broadcast({ type: "verify-result", result: { ok: true, summary } });
219
+ return;
220
+ }
221
+ const written = await writeVerificationKit(files, project);
222
+ broadcast({
223
+ type: "verify-result",
224
+ result: written.ok ? { ok: true, summary, dir: written.dir } : { ok: false, error: written.error },
225
+ });
226
+ } catch (error) {
227
+ broadcast({ type: "verify-result", result: { ok: false, error: (error as Error).message } });
228
+ } finally {
229
+ verifying = false;
230
+ }
231
+ }
232
+
132
233
  chromeServer.on("upgrade", (req, socket, head) => {
133
234
  if (req.url !== "/bridge") return; // Vite's HMR socket handles its own upgrades.
134
235
  if (!chromeOrigins.includes(req.headers.origin ?? "")) {
@@ -171,6 +272,13 @@ async function switchTo(entry: ProjectEntry, socket: WebSocket): Promise<void> {
171
272
  send(socket, { type: "project-error", message: "The agent is working. Switch projects when it is done." });
172
273
  return;
173
274
  }
275
+ if (verifying) {
276
+ send(socket, {
277
+ type: "project-error",
278
+ message: "A verification kit is being built. Switch projects when it is done.",
279
+ });
280
+ return;
281
+ }
174
282
  switching = true;
175
283
  const previous = current.entry;
176
284
  broadcast({ type: "project-switching", project: entry });
@@ -314,6 +422,9 @@ async function handle(message: ClientMessage, socket: WebSocket): Promise<void>
314
422
  send(socket, { type: "export-result", result });
315
423
  return;
316
424
  }
425
+ case "verify-kit":
426
+ await verificationKit(typeof message.project === "string" ? message.project : undefined, socket);
427
+ return;
317
428
  case "project-switch": {
318
429
  const entry = projects.find(String(message.id ?? ""));
319
430
  if (entry) await switchTo(entry, socket);
package/server/project.ts CHANGED
@@ -86,7 +86,7 @@ export class OpenProject {
86
86
  root,
87
87
  appType: "mpa",
88
88
  cacheDir: "node_modules/.vite/preview",
89
- optimizeDeps: { entries: ["preview.html"] },
89
+ optimizeDeps: { entries: ["preview.html", "capture.html"] },
90
90
  plugins: [rebasePlugin(root, entry.dir)],
91
91
  resolve: {
92
92
  alias: {
@@ -112,6 +112,12 @@ export class OpenProject {
112
112
  const previewGuard = guard((url) => url === "/" || url === "/index.html");
113
113
  previewServer.on("request", (req, res) => {
114
114
  if (!isLocalHost(req, options.previewPort)) return refuseHost(res);
115
+ // The capture page (capture.html) reads the current tokens from here, as the kit's copy reads its own file.
116
+ if (req.method === "GET" && req.url?.split("?")[0] === "/design.json") {
117
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
118
+ res.end(JSON.stringify(workspace.state.design));
119
+ return;
120
+ }
115
121
  previewGuard(req, res, () => previewVite.middlewares(req, res));
116
122
  });
117
123
  await new Promise<void>((resolve, reject) => {