openuispec 0.1.47 → 0.2.1

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
@@ -123,6 +123,7 @@ Or run directly: `openuispec mcp`
123
123
  | `openuispec_get_contract` | Incremental edits | Get a single contract spec, optionally filtered to one variant |
124
124
  | `openuispec_get_tokens` | Incremental edits | Get tokens for a specific category (color, typography, spacing, etc.) |
125
125
  | `openuispec_get_locale` | Incremental edits | Get a single locale file, optionally filtered to specific keys |
126
+ | `openuispec_screenshot` | Visual verification | Take a screenshot of the generated web app at a specific route (requires `puppeteer`) |
126
127
 
127
128
  The server includes **protocol-level instructions** that trigger on UI-related requests independently of CLAUDE.md rules — so even if CLAUDE.md is buried under other project rules, the MCP enforcement still works.
128
129
 
@@ -190,7 +191,8 @@ openuispec/
190
191
  │ ├── index.ts # Entry point
191
192
  │ └── init.ts # Project scaffolding + AI rules
192
193
  ├── mcp-server/ # MCP server (openuispec-mcp)
193
- └── index.ts # Stdio transport, 12 tools
194
+ ├── index.ts # Stdio transport, 13 tools
195
+ │ └── screenshot.ts # Dev server + headless browser screenshot
194
196
  ├── check/ # Composite validation command
195
197
  │ └── index.ts # Schema + semantic + readiness
196
198
  ├── drift/ # Drift detection (spec change tracking)
package/cli/init.ts CHANGED
@@ -282,6 +282,7 @@ When the openuispec MCP server is configured, AI assistants should use these too
282
282
  | \`openuispec_get_contract\` | Get a single contract spec, optionally filtered to one variant. |
283
283
  | \`openuispec_get_tokens\` | Get tokens for a specific category (color, typography, spacing, etc.). |
284
284
  | \`openuispec_get_locale\` | Get a single locale file, optionally filtered to specific keys. |
285
+ | \`openuispec_screenshot\` | Take a screenshot of the generated web app at a route (requires \`puppeteer\`). |
285
286
 
286
287
  ## CLI commands
287
288
 
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.45 -->
2
+ <!-- openuispec-rules-version: 0.1.47 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -1,5 +1,5 @@
1
1
  <!-- openuispec-rules-start -->
2
- <!-- openuispec-rules-version: 0.1.45 -->
2
+ <!-- openuispec-rules-version: 0.1.47 -->
3
3
  # OpenUISpec — AI Assistant Rules
4
4
  # ================================
5
5
  # This project uses OpenUISpec to define UI as a semantic spec.
@@ -25,6 +25,7 @@ import { loadTargetDrift } from "../drift/index.js";
25
25
  import { readFileSync as fsReadFileSync, existsSync, readdirSync } from "node:fs";
26
26
  import { relative, resolve } from "node:path";
27
27
  import YAML from "yaml";
28
+ import { takeScreenshot } from "./screenshot.js";
28
29
 
29
30
  // ── resolve project cwd ──────────────────────────────────────────────
30
31
 
@@ -111,6 +112,11 @@ FOCUSED GETTERS (prefer these for incremental edits over read_specs):
111
112
  - openuispec_check(target, screens?, contracts?) — scoped audit for specific screens/contracts
112
113
  Use read_specs for full-project generation; use focused getters when editing one screen or contract.
113
114
 
115
+ VISUAL VERIFICATION:
116
+ - openuispec_screenshot(route, viewport?, theme?) — screenshot the generated web app at a route.
117
+ Starts the dev server automatically. Use after generation to visually verify UI matches the spec.
118
+ Requires puppeteer (npm install -g puppeteer).
119
+
114
120
  Skip these tools ONLY when the request is purely non-UI (API logic, database, infrastructure, etc.)
115
121
  or explicitly platform-specific polish that doesn't affect shared UI semantics.`,
116
122
  }
@@ -622,6 +628,40 @@ server.registerTool(
622
628
  }
623
629
  );
624
630
 
631
+ // ── tool: openuispec_screenshot ──────────────────────────────────────
632
+
633
+ server.registerTool(
634
+ "openuispec_screenshot",
635
+ {
636
+ description: "Take a screenshot of the generated web app at a specific route. Starts the Vite dev server automatically if needed (first call may take longer). Returns a PNG image for visual verification of generated UI. Requires puppeteer to be installed (npm install -g puppeteer).",
637
+ inputSchema: {
638
+ route: z.string().default("/").describe("Route path to navigate to, e.g. '/home', '/settings', '/posts/123'"),
639
+ viewport: z.object({
640
+ width: z.number().default(1280),
641
+ height: z.number().default(800),
642
+ }).optional().describe("Viewport dimensions. Defaults to 1280x800. Use {width: 375, height: 812} for mobile."),
643
+ theme: z.enum(["light", "dark"]).optional().describe("Force a color scheme via prefers-color-scheme emulation"),
644
+ wait_for: z.number().optional().default(1000).describe("Milliseconds to wait after page load before screenshotting (default 1000)"),
645
+ full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
646
+ selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
647
+ },
648
+ },
649
+ async ({ route, viewport, theme, wait_for, full_page, selector }) => {
650
+ try {
651
+ return await takeScreenshot(projectCwd, {
652
+ route,
653
+ viewport,
654
+ theme,
655
+ wait_for,
656
+ full_page,
657
+ selector,
658
+ });
659
+ } catch (err) {
660
+ return toolError(err);
661
+ }
662
+ }
663
+ );
664
+
625
665
  // ── start server ─────────────────────────────────────────────────────
626
666
 
627
667
  export async function startMcpServer() {
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Screenshot tool — launches dev server + headless browser, captures pages.
3
+ *
4
+ * Both the Vite dev server and the Puppeteer browser are kept alive between
5
+ * calls and torn down when the MCP server process exits.
6
+ */
7
+
8
+ import { spawn, type ChildProcess, execSync } from "node:child_process";
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { createServer, type AddressInfo } from "node:net";
12
+ import YAML from "yaml";
13
+ import { findProjectDir } from "../drift/index.js";
14
+
15
+ // ── types ───────────────────────────────────────────────────────────
16
+
17
+ export interface ScreenshotOptions {
18
+ route: string;
19
+ viewport?: { width: number; height: number };
20
+ theme?: "light" | "dark";
21
+ wait_for?: number;
22
+ full_page?: boolean;
23
+ selector?: string;
24
+ }
25
+
26
+ export interface ScreenshotResult {
27
+ content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
28
+ isError?: true;
29
+ }
30
+
31
+ // ── free port finder ────────────────────────────────────────────────
32
+
33
+ function findFreePort(): Promise<number> {
34
+ return new Promise((resolve, reject) => {
35
+ const srv = createServer();
36
+ srv.listen(0, () => {
37
+ const port = (srv.address() as AddressInfo).port;
38
+ srv.close(() => resolve(port));
39
+ });
40
+ srv.on("error", reject);
41
+ });
42
+ }
43
+
44
+ // ── web app directory discovery ─────────────────────────────────────
45
+
46
+ export function findWebAppDir(projectCwd: string): string {
47
+ const projectDir = findProjectDir(projectCwd);
48
+ const manifestPath = join(projectDir, "openuispec.yaml");
49
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
50
+ const projectName = manifest.project?.name ?? "app";
51
+
52
+ // Check custom output_dir first
53
+ const customDir = manifest.generation?.output_dir?.web;
54
+ if (customDir) {
55
+ const resolved = resolve(projectDir, customDir);
56
+ if (existsSync(join(resolved, "package.json"))) return resolved;
57
+ }
58
+
59
+ // Default: generated/web/<project-name>/
60
+ // Try from the project root (parent of openuispec/)
61
+ const projectRoot = resolve(projectDir, "..");
62
+ const defaultDir = join(projectRoot, "generated", "web", projectName);
63
+ if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
64
+
65
+ throw new Error(
66
+ `Web app not found. Checked:\n` +
67
+ (customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
68
+ ` - ${defaultDir}\n` +
69
+ `Generate the web target first, then try again.`,
70
+ );
71
+ }
72
+
73
+ // ── dev server manager ──────────────────────────────────────────────
74
+
75
+ interface ServerInstance {
76
+ process: ChildProcess;
77
+ port: number;
78
+ url: string;
79
+ }
80
+
81
+ const servers = new Map<string, ServerInstance>();
82
+
83
+ function ensureDepsInstalled(webDir: string): void {
84
+ if (existsSync(join(webDir, "node_modules"))) return;
85
+ try {
86
+ execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
87
+ } catch (err) {
88
+ throw new Error(`Failed to install web app dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
89
+ }
90
+ }
91
+
92
+ async function startDevServer(webDir: string): Promise<ServerInstance> {
93
+ const existing = servers.get(webDir);
94
+ if (existing) {
95
+ // Verify still running
96
+ if (existing.process.exitCode === null) return existing;
97
+ servers.delete(webDir);
98
+ }
99
+
100
+ ensureDepsInstalled(webDir);
101
+ const port = await findFreePort();
102
+
103
+ const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
104
+ cwd: webDir,
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
107
+ });
108
+
109
+ // Wait for "Local:" line from Vite
110
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
111
+
112
+ const url = await new Promise<string>((resolveUrl, reject) => {
113
+ const timeout = setTimeout(() => {
114
+ child.kill();
115
+ reject(new Error("Vite dev server failed to start within 30s"));
116
+ }, 30_000);
117
+
118
+ let output = "";
119
+ const onData = (chunk: Buffer) => {
120
+ output += chunk.toString();
121
+ const clean = stripAnsi(output);
122
+ const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
123
+ if (match) {
124
+ clearTimeout(timeout);
125
+ child.stdout?.off("data", onData);
126
+ child.stderr?.off("data", onData);
127
+ resolveUrl(match[1]);
128
+ }
129
+ };
130
+ child.stdout?.on("data", onData);
131
+ child.stderr?.on("data", onData);
132
+ child.on("error", (err) => { clearTimeout(timeout); reject(err); });
133
+ child.on("exit", (code) => {
134
+ clearTimeout(timeout);
135
+ if (!output.includes("Local:")) {
136
+ reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
137
+ }
138
+ });
139
+ });
140
+
141
+ const instance: ServerInstance = { process: child, port, url };
142
+ servers.set(webDir, instance);
143
+ return instance;
144
+ }
145
+
146
+ // ── browser manager ─────────────────────────────────────────────────
147
+
148
+ let browserInstance: any = null;
149
+
150
+ async function getBrowser(): Promise<any> {
151
+ if (browserInstance?.connected) return browserInstance;
152
+
153
+ let puppeteer: any;
154
+ try {
155
+ puppeteer = await import("puppeteer");
156
+ } catch {
157
+ throw new Error(
158
+ "puppeteer is not installed. Run:\n npm install -g puppeteer\n" +
159
+ "or add it to your project's devDependencies.",
160
+ );
161
+ }
162
+
163
+ browserInstance = await puppeteer.launch({
164
+ headless: true,
165
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
166
+ });
167
+ return browserInstance;
168
+ }
169
+
170
+ // ── screenshot capture ──────────────────────────────────────────────
171
+
172
+ export async function takeScreenshot(
173
+ projectCwd: string,
174
+ options: ScreenshotOptions,
175
+ ): Promise<ScreenshotResult> {
176
+ const {
177
+ route = "/",
178
+ viewport = { width: 1280, height: 800 },
179
+ theme,
180
+ wait_for = 1000,
181
+ full_page = false,
182
+ selector,
183
+ } = options;
184
+
185
+ // 1. Find and start
186
+ const webDir = findWebAppDir(projectCwd);
187
+ const server = await startDevServer(webDir);
188
+ const browser = await getBrowser();
189
+
190
+ // 2. Navigate
191
+ const page = await browser.newPage();
192
+ try {
193
+ await page.setViewport({ width: viewport.width, height: viewport.height });
194
+
195
+ if (theme) {
196
+ await page.emulateMediaFeatures([
197
+ { name: "prefers-color-scheme", value: theme },
198
+ ]);
199
+ }
200
+
201
+ const base = server.url.replace(/\/+$/, "");
202
+ const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
203
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
204
+
205
+ if (wait_for > 0) {
206
+ await new Promise((r) => setTimeout(r, wait_for));
207
+ }
208
+
209
+ // 3. Screenshot
210
+ let buffer: Buffer;
211
+ if (selector) {
212
+ const element = await page.$(selector);
213
+ if (!element) {
214
+ return {
215
+ content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
216
+ isError: true,
217
+ };
218
+ }
219
+ buffer = await element.screenshot({ type: "png" });
220
+ } else {
221
+ buffer = await page.screenshot({ type: "png", fullPage: full_page });
222
+ }
223
+
224
+ const base64 = buffer.toString("base64");
225
+
226
+ return {
227
+ content: [
228
+ { type: "image" as const, data: base64, mimeType: "image/png" },
229
+ {
230
+ type: "text" as const,
231
+ text: JSON.stringify({
232
+ route,
233
+ url: targetUrl,
234
+ viewport,
235
+ theme: theme ?? "default",
236
+ full_page,
237
+ selector: selector ?? null,
238
+ }, null, 2),
239
+ },
240
+ ],
241
+ };
242
+ } finally {
243
+ await page.close();
244
+ }
245
+ }
246
+
247
+ // ── cleanup ─────────────────────────────────────────────────────────
248
+
249
+ export async function shutdownAll() {
250
+ for (const [, instance] of servers) {
251
+ try { instance.process.kill(); } catch { /* already dead */ }
252
+ }
253
+ servers.clear();
254
+ if (browserInstance) {
255
+ try { await browserInstance.close(); } catch { /* ignore */ }
256
+ browserInstance = null;
257
+ }
258
+ }
259
+
260
+ process.on("exit", () => {
261
+ // Sync-only fallback for process exit
262
+ for (const [, instance] of servers) {
263
+ try { instance.process.kill(); } catch { /* already dead */ }
264
+ }
265
+ servers.clear();
266
+ });
267
+ process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
268
+ process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.1.47",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -28,7 +28,9 @@
28
28
  "openuispec-mcp": "./mcp-server/index.ts"
29
29
  },
30
30
  "scripts": {
31
- "test": "node --import tsx --test tests/*.test.ts",
31
+ "test": "node --import tsx --test tests/check.test.ts tests/configure-target.test.ts tests/drift-prepare.test.ts tests/init.test.ts tests/mcp-tools.test.ts tests/semantic-lint.test.ts tests/status.test.ts",
32
+ "test:screenshot": "node --import tsx --test tests/mcp-screenshot.test.ts",
33
+ "test:all": "node --import tsx --test tests/*.test.ts",
32
34
  "validate": "tsx schema/validate.ts",
33
35
  "validate:manifest": "tsx schema/validate.ts manifest",
34
36
  "validate:tokens": "tsx schema/validate.ts tokens",
@@ -49,6 +51,9 @@
49
51
  "tsx": "^4.19.4",
50
52
  "yaml": "^2.7.1"
51
53
  },
54
+ "optionalDependencies": {
55
+ "puppeteer": "^24.39.1"
56
+ },
52
57
  "devDependencies": {
53
58
  "@types/node": "^25.5.0",
54
59
  "typescript": "^5.8.3"