openuispec 0.2.14 → 0.2.16

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.
Files changed (53) hide show
  1. package/README.md +4 -2
  2. package/check/audit.ts +251 -0
  3. package/check/index.ts +19 -3
  4. package/cli/init.ts +1 -0
  5. package/docs/cli.md +82 -3
  6. package/docs/file-formats.md +83 -0
  7. package/docs/implementation-notes.md +8 -0
  8. package/examples/social-app/openuispec/contracts/action_trigger.yaml +8 -0
  9. package/examples/social-app/openuispec/contracts/collection.yaml +8 -0
  10. package/examples/social-app/openuispec/contracts/data_display.yaml +8 -0
  11. package/examples/social-app/openuispec/contracts/feedback.yaml +8 -0
  12. package/examples/social-app/openuispec/contracts/input_field.yaml +8 -0
  13. package/examples/social-app/openuispec/contracts/nav_container.yaml +9 -0
  14. package/examples/social-app/openuispec/contracts/surface.yaml +8 -0
  15. package/examples/social-app/openuispec/openuispec.yaml +40 -0
  16. package/examples/social-app/openuispec/tokens/color.yaml +4 -0
  17. package/examples/social-app/openuispec/tokens/motion.yaml +4 -0
  18. package/examples/social-app/openuispec/tokens/typography.yaml +11 -0
  19. package/examples/taskflow/openuispec/contracts/action_trigger.yaml +9 -1
  20. package/examples/taskflow/openuispec/contracts/collection.yaml +9 -1
  21. package/examples/taskflow/openuispec/contracts/data_display.yaml +9 -1
  22. package/examples/taskflow/openuispec/contracts/feedback.yaml +9 -1
  23. package/examples/taskflow/openuispec/contracts/input_field.yaml +8 -0
  24. package/examples/taskflow/openuispec/contracts/nav_container.yaml +10 -1
  25. package/examples/taskflow/openuispec/contracts/surface.yaml +9 -1
  26. package/examples/taskflow/openuispec/openuispec.yaml +40 -0
  27. package/examples/taskflow/openuispec/tokens/color.yaml +4 -0
  28. package/examples/taskflow/openuispec/tokens/motion.yaml +4 -0
  29. package/examples/taskflow/openuispec/tokens/typography.yaml +11 -0
  30. package/examples/todo-orbit/openuispec/contracts/action_trigger.yaml +7 -0
  31. package/examples/todo-orbit/openuispec/contracts/collection.yaml +7 -0
  32. package/examples/todo-orbit/openuispec/contracts/data_display.yaml +7 -0
  33. package/examples/todo-orbit/openuispec/contracts/feedback.yaml +7 -0
  34. package/examples/todo-orbit/openuispec/contracts/input_field.yaml +7 -0
  35. package/examples/todo-orbit/openuispec/contracts/nav_container.yaml +8 -0
  36. package/examples/todo-orbit/openuispec/contracts/surface.yaml +7 -0
  37. package/examples/todo-orbit/openuispec/openuispec.yaml +40 -0
  38. package/examples/todo-orbit/openuispec/tokens/color.yaml +4 -0
  39. package/examples/todo-orbit/openuispec/tokens/motion.yaml +4 -0
  40. package/examples/todo-orbit/openuispec/tokens/typography.yaml +11 -0
  41. package/mcp-server/index.ts +22 -6
  42. package/mcp-server/screenshot-shared.ts +3 -4
  43. package/mcp-server/screenshot.ts +285 -70
  44. package/package.json +1 -1
  45. package/prepare/index.ts +96 -0
  46. package/schema/component.schema.json +5 -0
  47. package/schema/contract.schema.json +11 -1
  48. package/schema/custom-contract.schema.json +5 -0
  49. package/schema/openuispec.schema.json +47 -0
  50. package/schema/semantic-lint.ts +5 -3
  51. package/schema/tokens/color.schema.json +5 -0
  52. package/schema/tokens/motion.schema.json +5 -0
  53. package/schema/tokens/typography.schema.json +10 -0
@@ -48,10 +48,9 @@ export async function closeBrowser(): Promise<void> {
48
48
 
49
49
  // ── shared result type ──────────────────────────────────────────────
50
50
 
51
- export interface ScreenshotResult {
52
- content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
53
- isError?: true;
54
- }
51
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
52
+
53
+ export type ScreenshotResult = CallToolResult;
55
54
 
56
55
  // ── manifest loading ────────────────────────────────────────────────
57
56
 
@@ -8,7 +8,7 @@
8
8
  import { spawn, type ChildProcess, execSync } from "node:child_process";
9
9
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join, resolve } from "node:path";
11
- import { createServer, type AddressInfo } from "node:net";
11
+ import { createConnection } from "node:net";
12
12
  import YAML from "yaml";
13
13
  import { findProjectDir } from "../drift/index.js";
14
14
  import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
@@ -24,18 +24,186 @@ export interface ScreenshotOptions {
24
24
  full_page?: boolean;
25
25
  selector?: string;
26
26
  output_dir?: string;
27
+ init_script?: string;
27
28
  }
28
29
 
29
- // ── free port finder ────────────────────────────────────────────────
30
+ // ── framework config table ───────────────────────────────────────────
31
+
32
+ interface FrameworkConfig {
33
+ /** Typed discriminant for branching logic */
34
+ kind: "node" | "bun" | "deno" | "python-django" | "python-flask" | "ruby" | "php" | "go" | "rust" | "java";
35
+ /** Human-readable name */
36
+ name: string;
37
+ /** Files whose presence identifies this framework (checked in webDir) */
38
+ indicators: string[];
39
+ /** Default dev port when none is configured */
40
+ defaultPort: number;
41
+ /** Command + args to start the dev server; PORT placeholder replaced at runtime */
42
+ devCommand: string[];
43
+ /** Install command to run when dependencies are missing (null = skip) */
44
+ installCommand: string[] | null;
45
+ /** Package manager env var used to set the port (e.g. PORT, APP_PORT) */
46
+ portEnvVar: string;
47
+ }
30
48
 
31
- function findFreePort(): Promise<number> {
32
- return new Promise((resolve, reject) => {
33
- const srv = createServer();
34
- srv.listen(0, () => {
35
- const port = (srv.address() as AddressInfo).port;
36
- srv.close(() => resolve(port));
37
- });
38
- srv.on("error", reject);
49
+ // Ordered by detection priority (most specific first)
50
+ const FRAMEWORKS: FrameworkConfig[] = [
51
+ {
52
+ kind: "bun",
53
+ name: "Bun",
54
+ indicators: ["bun.lockb", "bun.lock"],
55
+ defaultPort: 3000,
56
+ devCommand: ["bun", "run", "dev"],
57
+ installCommand: ["bun", "install"],
58
+ portEnvVar: "PORT",
59
+ },
60
+ {
61
+ kind: "deno",
62
+ name: "Deno",
63
+ indicators: ["deno.json", "deno.jsonc"],
64
+ defaultPort: 8000,
65
+ devCommand: ["deno", "task", "dev"],
66
+ installCommand: null,
67
+ portEnvVar: "PORT",
68
+ },
69
+ {
70
+ kind: "node",
71
+ name: "Node.js (npm/yarn/pnpm)",
72
+ indicators: ["package.json"],
73
+ defaultPort: 3000,
74
+ devCommand: ["npm", "run", "dev"],
75
+ installCommand: ["npm", "install"],
76
+ portEnvVar: "PORT",
77
+ },
78
+ {
79
+ kind: "python-django",
80
+ name: "Django",
81
+ indicators: ["manage.py"],
82
+ defaultPort: 8000,
83
+ devCommand: ["python", "manage.py", "runserver", "PORT"],
84
+ installCommand: null,
85
+ portEnvVar: "PORT",
86
+ },
87
+ {
88
+ kind: "python-flask",
89
+ name: "Flask / FastAPI",
90
+ indicators: ["requirements.txt", "Pipfile", "pyproject.toml"],
91
+ defaultPort: 5000,
92
+ devCommand: ["python", "-m", "flask", "run", "--port", "PORT"],
93
+ installCommand: null,
94
+ portEnvVar: "PORT",
95
+ },
96
+ {
97
+ kind: "ruby",
98
+ name: "Ruby on Rails",
99
+ indicators: ["Gemfile", "config/application.rb"],
100
+ defaultPort: 3000,
101
+ devCommand: ["bin/rails", "server", "-p", "PORT"],
102
+ installCommand: ["bundle", "install"],
103
+ portEnvVar: "PORT",
104
+ },
105
+ {
106
+ kind: "php",
107
+ name: "PHP (Laravel)",
108
+ indicators: ["artisan"],
109
+ defaultPort: 8000,
110
+ devCommand: ["php", "artisan", "serve", "--port=PORT"],
111
+ installCommand: null,
112
+ portEnvVar: "PORT",
113
+ },
114
+ {
115
+ kind: "go",
116
+ name: "Go",
117
+ indicators: ["go.mod"],
118
+ defaultPort: 8080,
119
+ devCommand: ["go", "run", "."],
120
+ installCommand: null,
121
+ portEnvVar: "PORT",
122
+ },
123
+ {
124
+ kind: "rust",
125
+ name: "Rust (Trunk)",
126
+ indicators: ["Trunk.toml", "Cargo.toml"],
127
+ defaultPort: 8080,
128
+ devCommand: ["trunk", "serve", "--port", "PORT"],
129
+ installCommand: null,
130
+ portEnvVar: "PORT",
131
+ },
132
+ {
133
+ kind: "java",
134
+ name: "Java / Spring Boot",
135
+ indicators: ["pom.xml", "build.gradle", "build.gradle.kts"],
136
+ defaultPort: 8080,
137
+ devCommand: ["./mvnw", "spring-boot:run"],
138
+ installCommand: null,
139
+ portEnvVar: "SERVER_PORT",
140
+ },
141
+ ];
142
+
143
+ // ── framework detection ──────────────────────────────────────────────
144
+
145
+ function detectFramework(webDir: string): FrameworkConfig {
146
+ for (const fw of FRAMEWORKS) {
147
+ if (fw.indicators.some((f) => existsSync(join(webDir, f)))) return fw;
148
+ }
149
+ // Fallback: generic Node
150
+ return FRAMEWORKS.find((f) => f.kind === "node")!;
151
+ }
152
+
153
+ // ── port resolution ──────────────────────────────────────────────────
154
+
155
+ /** Parse --port / -p / --port=N from a script string. */
156
+ function parsePortFromScript(script: string): number | null {
157
+ const m = script.match(/(?:--port|-p)[=\s]+(\d+)/);
158
+ return m ? parseInt(m[1], 10) : null;
159
+ }
160
+
161
+ /** Read PORT (or custom var) from .env.local / .env.development / .env. */
162
+ function readEnvPort(webDir: string, varName = "PORT"): number | null {
163
+ for (const name of [".env.local", ".env.development", ".env"]) {
164
+ const envPath = join(webDir, name);
165
+ if (!existsSync(envPath)) continue;
166
+ try {
167
+ const re = new RegExp(`^\\s*${varName}\\s*=\\s*(\\d+)`, "m");
168
+ const m = readFileSync(envPath, "utf-8").match(re);
169
+ if (m) return parseInt(m[1], 10);
170
+ } catch { /* skip */ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** Resolve the port this project's dev server will use. */
176
+ function resolvePort(webDir: string, fw: FrameworkConfig): number {
177
+ // 1. .env files
178
+ const envPort = readEnvPort(webDir, fw.portEnvVar);
179
+ if (envPort) return envPort;
180
+
181
+ // 2. package.json dev/start script (Node-like only)
182
+ if (existsSync(join(webDir, "package.json"))) {
183
+ try {
184
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
185
+ const scripts: Record<string, string> = pkg.scripts ?? {};
186
+ for (const name of ["dev", "start", "serve", "develop"]) {
187
+ if (scripts[name]) {
188
+ const p = parsePortFromScript(scripts[name]);
189
+ if (p) return p;
190
+ break;
191
+ }
192
+ }
193
+ } catch { /* ignore */ }
194
+ }
195
+
196
+ // 3. Framework default
197
+ return fw.defaultPort;
198
+ }
199
+
200
+ function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
201
+ return new Promise((resolve) => {
202
+ const socket = createConnection({ port, host });
203
+ socket.setTimeout(500);
204
+ socket.on("connect", () => { socket.destroy(); resolve(true); });
205
+ socket.on("timeout", () => { socket.destroy(); resolve(false); });
206
+ socket.on("error", () => resolve(false));
39
207
  });
40
208
  }
41
209
 
@@ -47,18 +215,22 @@ export function findWebAppDir(projectCwd: string): string {
47
215
  const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
48
216
  const projectName = manifest.project?.name ?? "app";
49
217
 
218
+ // Derive indicators from the FRAMEWORKS table so they stay in sync
219
+ const isWebDir = (d: string) =>
220
+ FRAMEWORKS.some((fw) => fw.indicators.some((f) => existsSync(join(d, f))));
221
+
50
222
  // Check custom output_dir first
51
223
  const customDir = manifest.generation?.output_dir?.web;
52
224
  if (customDir) {
53
225
  const resolved = resolve(projectDir, customDir);
54
- if (existsSync(join(resolved, "package.json"))) return resolved;
226
+ if (isWebDir(resolved)) return resolved;
55
227
  }
56
228
 
57
229
  // Default: generated/web/<project-name>/
58
230
  // Try from the project root (parent of openuispec/)
59
231
  const projectRoot = resolve(projectDir, "..");
60
232
  const defaultDir = join(projectRoot, "generated", "web", projectName);
61
- if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
233
+ if (isWebDir(defaultDir)) return defaultDir;
62
234
 
63
235
  throw new Error(
64
236
  `Web app not found. Checked:\n` +
@@ -71,78 +243,114 @@ export function findWebAppDir(projectCwd: string): string {
71
243
  // ── dev server manager ──────────────────────────────────────────────
72
244
 
73
245
  interface ServerInstance {
74
- process: ChildProcess;
246
+ process: ChildProcess | null; // null = using an externally running server
75
247
  port: number;
76
248
  url: string;
77
249
  }
78
250
 
79
251
  const servers = new Map<string, ServerInstance>();
80
252
 
81
- function ensureDepsInstalled(webDir: string): void {
82
- if (existsSync(join(webDir, "node_modules"))) return;
253
+ function ensureDepsInstalled(webDir: string, fw: FrameworkConfig): void {
254
+ if (!fw.installCommand) return;
255
+ // For Node.js check node_modules; for others always run
256
+ if (fw.kind === "node" && existsSync(join(webDir, "node_modules"))) return;
83
257
  try {
84
- execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
258
+ execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
85
259
  } catch (err) {
86
- throw new Error(`Failed to install web app dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
260
+ throw new Error(`Failed to install dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
87
261
  }
88
262
  }
89
263
 
264
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
265
+
266
+ /** Poll until the port accepts connections, or the timeout expires. */
267
+ async function waitForPort(port: number, timeoutMs = 60_000): Promise<boolean> {
268
+ const deadline = Date.now() + timeoutMs;
269
+ while (Date.now() < deadline) {
270
+ if (await isPortListening(port)) return true;
271
+ await new Promise((r) => setTimeout(r, 500));
272
+ }
273
+ return false;
274
+ }
275
+
90
276
  async function startDevServer(webDir: string): Promise<ServerInstance> {
91
277
  const existing = servers.get(webDir);
92
278
  if (existing) {
93
- // Verify still running
94
- if (existing.process.exitCode === null) return existing;
279
+ const alive = existing.process === null
280
+ ? await isPortListening(existing.port) // external server
281
+ : existing.process.exitCode === null; // managed process
282
+ if (alive) return existing;
95
283
  servers.delete(webDir);
96
284
  }
97
285
 
98
- ensureDepsInstalled(webDir);
99
- const port = await findFreePort();
286
+ const fw = detectFramework(webDir);
287
+ const port = resolvePort(webDir, fw);
288
+
289
+ // Always prefer an already-running server on the expected port
290
+ if (await isPortListening(port)) {
291
+ const instance: ServerInstance = { process: null, port, url: `http://localhost:${port}` };
292
+ servers.set(webDir, instance);
293
+ return instance;
294
+ }
100
295
 
101
- const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
296
+ // Start the dev server for this framework
297
+ ensureDepsInstalled(webDir, fw);
298
+
299
+ // Build command: replace "PORT" placeholder with actual port string
300
+ const [cmd, ...args] = fw.devCommand.map((part) => part === "PORT" ? String(port) : part);
301
+
302
+ // For Node.js, use the project's own dev script from package.json if available
303
+ let spawnCmd = cmd;
304
+ let spawnArgs = args;
305
+ if (fw.kind === "node" && existsSync(join(webDir, "package.json"))) {
306
+ try {
307
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
308
+ const scripts: Record<string, string> = pkg.scripts ?? {};
309
+ const scriptName = ["dev", "start", "serve", "develop"].find((n) => n in scripts);
310
+ if (scriptName) {
311
+ spawnCmd = "npm";
312
+ spawnArgs = ["run", scriptName];
313
+ }
314
+ } catch { /* ignore */ }
315
+ }
316
+
317
+ const child = spawn(spawnCmd, spawnArgs, {
102
318
  cwd: webDir,
103
319
  stdio: ["ignore", "pipe", "pipe"],
104
- env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
320
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
105
321
  });
106
322
 
107
- // Wait for "Local:" line from Vite
108
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
109
-
110
- const url = await new Promise<string>((resolveUrl, reject) => {
111
- const timeout = setTimeout(() => {
112
- child.kill();
113
- reject(new Error("Vite dev server failed to start within 30s"));
114
- }, 30_000);
115
-
116
- let output = "";
117
- const onData = (chunk: Buffer) => {
118
- output += chunk.toString();
119
- const clean = stripAnsi(output);
120
- const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
121
- if (match) {
122
- clearTimeout(timeout);
123
- child.stdout?.off("data", onData);
124
- child.stderr?.off("data", onData);
125
- resolveUrl(match[1]);
126
- }
127
- };
128
- child.stdout?.on("data", onData);
129
- child.stderr?.on("data", onData);
130
- child.on("error", (err) => { clearTimeout(timeout); reject(err); });
131
- child.on("exit", (code) => {
132
- clearTimeout(timeout);
133
- if (!output.includes("Local:")) {
134
- reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
135
- }
136
- });
137
- });
323
+ // Collect stderr from the start so we have output for error messages
324
+ let stderr = "";
325
+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
326
+
327
+ // Wait for the port to open (framework-agnostic no stdout parsing needed)
328
+ const ready = await waitForPort(port, 60_000);
329
+
330
+ if (!ready) {
331
+ child.kill();
332
+ throw new Error(
333
+ `${fw.name} dev server did not open port ${port} within 60s.\n` +
334
+ (stderr ? `stderr:\n${stripAnsi(stderr).slice(-500)}` : ""),
335
+ );
336
+ }
138
337
 
139
- const instance: ServerInstance = { process: child, port, url };
338
+ const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
140
339
  servers.set(webDir, instance);
141
340
  return instance;
142
341
  }
143
342
 
144
343
  // ── browser manager (imported from screenshot-shared.ts) ────────────
145
344
 
345
+ // ── init_script URL injection ────────────────────────────────────────
346
+
347
+ /** Append ?__ous_init=<base64> to a URL, respecting existing query params. */
348
+ function appendInitParam(targetUrl: string, initScript: string): string {
349
+ const url = new URL(targetUrl);
350
+ url.searchParams.set("__ous_init", Buffer.from(initScript).toString("base64"));
351
+ return url.toString();
352
+ }
353
+
146
354
  // ── screenshot capture ──────────────────────────────────────────────
147
355
 
148
356
  export async function takeScreenshot(
@@ -158,6 +366,7 @@ export async function takeScreenshot(
158
366
  full_page = false,
159
367
  selector,
160
368
  output_dir,
369
+ init_script,
161
370
  } = options;
162
371
 
163
372
  // 1. Find and start
@@ -181,7 +390,8 @@ export async function takeScreenshot(
181
390
  }
182
391
 
183
392
  const base = server.url.replace(/\/+$/, "");
184
- const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
393
+ let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
394
+ if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
185
395
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
186
396
 
187
397
  if (wait_for > 0) {
@@ -230,6 +440,7 @@ export async function takeScreenshot(
230
440
  full_page,
231
441
  selector: selector ?? null,
232
442
  path: savedPath ?? null,
443
+ init_script: init_script ?? null,
233
444
  }, null, 2),
234
445
  },
235
446
  ],
@@ -247,6 +458,7 @@ export interface WebBatchCapture {
247
458
  selector?: string;
248
459
  full_page?: boolean;
249
460
  wait_for?: number;
461
+ init_script?: string;
250
462
  }
251
463
 
252
464
  export interface WebScreenshotBatchOptions {
@@ -255,6 +467,7 @@ export interface WebScreenshotBatchOptions {
255
467
  scale?: number;
256
468
  theme?: "light" | "dark";
257
469
  output_dir?: string;
470
+ init_script?: string;
258
471
  }
259
472
 
260
473
  // ── batch screenshot ─────────────────────────────────────────────────
@@ -263,7 +476,7 @@ export async function takeScreenshotBatch(
263
476
  projectCwd: string,
264
477
  options: WebScreenshotBatchOptions,
265
478
  ): Promise<ScreenshotResult> {
266
- const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir } = options;
479
+ const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
267
480
 
268
481
  if (captures.length === 0) {
269
482
  return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
@@ -286,10 +499,12 @@ export async function takeScreenshotBatch(
286
499
 
287
500
  const base = server.url.replace(/\/+$/, "");
288
501
  const themeLabel = theme ?? "default";
289
- const snapshots: Array<{ screen: string; path: string; data: string }> = [];
502
+ const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
290
503
 
291
504
  for (const capture of captures) {
292
- const targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
505
+ const effectiveInitScript = capture.init_script ?? sharedInitScript;
506
+ let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
507
+ if (effectiveInitScript) targetUrl = appendInitParam(targetUrl, effectiveInitScript);
293
508
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
294
509
  await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
295
510
 
@@ -310,7 +525,7 @@ export async function takeScreenshotBatch(
310
525
  writeFileSync(savedPath, buffer);
311
526
  }
312
527
 
313
- snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64") });
528
+ snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
314
529
  }
315
530
 
316
531
  const content: ScreenshotResult["content"] = [];
@@ -318,7 +533,7 @@ export async function takeScreenshotBatch(
318
533
  content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
319
534
  content.push({
320
535
  type: "text" as const,
321
- text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel }, null, 2),
536
+ text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
322
537
  });
323
538
  }
324
539
  return { content };
@@ -329,20 +544,20 @@ export async function takeScreenshotBatch(
329
544
 
330
545
  // ── cleanup ─────────────────────────────────────────────────────────
331
546
 
332
- export async function shutdownAll() {
547
+ function killAllServers() {
333
548
  for (const [, instance] of servers) {
334
- try { instance.process.kill(); } catch { /* already dead */ }
549
+ if (instance.process) {
550
+ try { instance.process.kill(); } catch { /* already dead */ }
551
+ }
335
552
  }
336
553
  servers.clear();
554
+ }
555
+
556
+ export async function shutdownAll() {
557
+ killAllServers();
337
558
  await closeBrowser();
338
559
  }
339
560
 
340
- process.on("exit", () => {
341
- // Sync-only fallback for process exit
342
- for (const [, instance] of servers) {
343
- try { instance.process.kill(); } catch { /* already dead */ }
344
- }
345
- servers.clear();
346
- });
561
+ process.on("exit", killAllServers);
347
562
  process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
348
563
  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.2.14",
3
+ "version": "0.2.16",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
package/prepare/index.ts CHANGED
@@ -172,6 +172,17 @@ export interface PrepareResult {
172
172
  shared_layers?: SharedLayerInfo[];
173
173
  bootstrap?: PrepareBootstrapBundle;
174
174
  spec_contents?: SpecFileContent[];
175
+ anti_patterns?: {
176
+ universal: Record<string, string[]>;
177
+ contract_specific: Record<string, string[]>;
178
+ project_specific: string[];
179
+ };
180
+ design_context?: {
181
+ personality?: string;
182
+ complexity: 'restrained' | 'balanced' | 'elaborate';
183
+ audience?: string;
184
+ complexity_rule: string;
185
+ };
175
186
  next_steps: string[];
176
187
  }
177
188
 
@@ -643,9 +654,90 @@ function generationRules(target: string, outputDir: string, manifest: Record<str
643
654
  rules.push(`Target "${target}" scope: ${structure.scope}`);
644
655
  }
645
656
 
657
+ // Include extra_rules from manifest, filtered by target platform tag
658
+ const extraRules: string[] = Array.isArray(manifest.generation?.extra_rules)
659
+ ? manifest.generation.extra_rules.filter((rule: any): rule is string => typeof rule === "string")
660
+ : [];
661
+ for (const rule of extraRules) {
662
+ if (matchesTargetPlatform(rule, target)) rules.push(rule);
663
+ }
664
+
646
665
  return rules;
647
666
  }
648
667
 
668
+ function matchesTargetPlatform(item: string, target: string): boolean {
669
+ const tagMatch = item.match(/^\[([a-z]+)\]/);
670
+ return !tagMatch || tagMatch[1] === target;
671
+ }
672
+
673
+ function complexityRule(complexity: string): string {
674
+ switch (complexity) {
675
+ case 'restrained':
676
+ return 'Minimal motion (required state transitions only). No decorative shadows. Clean whitespace. Precise token application. No background effects.';
677
+ case 'elaborate':
678
+ return 'Rich animations with staggered reveals. Creative elevation. Platform-specific flourishes.';
679
+ default:
680
+ return 'Apply all motion.patterns. Use elevation tokens fully. Standard state animations.';
681
+ }
682
+ }
683
+
684
+ function buildDesignContext(manifest: Record<string, any>): PrepareResult['design_context'] {
685
+ const design = manifest.design;
686
+ if (!design) return undefined;
687
+ const complexity = (design.complexity as 'restrained' | 'balanced' | 'elaborate') ?? 'balanced';
688
+ return {
689
+ ...(design.personality ? { personality: design.personality } : {}),
690
+ complexity,
691
+ ...(design.audience ? { audience: design.audience } : {}),
692
+ complexity_rule: complexityRule(complexity),
693
+ };
694
+ }
695
+
696
+ function buildAntiPatterns(
697
+ manifest: Record<string, any>,
698
+ projectDir: string,
699
+ target: string
700
+ ): PrepareResult['anti_patterns'] {
701
+ // Universal anti-patterns from generation_guidance
702
+ const universal: Record<string, string[]> = {};
703
+ const universalRaw = manifest.generation_guidance?.universal_anti_patterns ?? {};
704
+ for (const [domain, items] of Object.entries(universalRaw)) {
705
+ if (Array.isArray(items)) {
706
+ const filtered = (items as string[]).filter((item) => matchesTargetPlatform(item, target));
707
+ if (filtered.length > 0) universal[domain] = filtered;
708
+ }
709
+ }
710
+
711
+ // Contract-specific must_avoid
712
+ const contract_specific: Record<string, string[]> = {};
713
+ try {
714
+ const contractsDir = resolve(projectDir, manifest.includes?.contracts ?? './contracts/');
715
+ if (existsSync(contractsDir)) {
716
+ for (const file of readdirSync(contractsDir).filter((f) => f.endsWith('.yaml') && !f.startsWith('x_'))) {
717
+ const content = YAML.parse(readFileSync(join(contractsDir, file), 'utf-8'));
718
+ const contractName = Object.keys(content)[0];
719
+ const mustAvoid: string[] = content[contractName]?.generation?.must_avoid ?? [];
720
+ if (mustAvoid.length > 0) {
721
+ const filtered = mustAvoid.filter((item: string) => matchesTargetPlatform(item, target));
722
+ if (filtered.length > 0) contract_specific[contractName] = filtered;
723
+ }
724
+ }
725
+ }
726
+ } catch { /* skip on error */ }
727
+
728
+ // Project-specific avoid from design section
729
+ const project_specific: string[] = [];
730
+ const designAvoid: string[] = manifest.design?.avoid ?? [];
731
+ for (const item of designAvoid) {
732
+ if (matchesTargetPlatform(item, target)) project_specific.push(item);
733
+ }
734
+
735
+ const hasContent = Object.keys(universal).length > 0 || Object.keys(contract_specific).length > 0 || project_specific.length > 0;
736
+ if (!hasContent) return undefined;
737
+
738
+ return { universal, contract_specific, project_specific };
739
+ }
740
+
649
741
  function localizationConstraints(
650
742
  target: string,
651
743
  platformConfig?: Pick<PreparePlatformConfig, "framework">
@@ -1228,6 +1320,8 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1228
1320
  const outputDirExists = existsSync(outputDir);
1229
1321
  const snapshotPath = join(outputDir, ".openuispec-state.json");
1230
1322
  const snapshotFileExists = existsSync(snapshotPath);
1323
+ const antiPatterns = buildAntiPatterns(manifest, projectDir, target);
1324
+ const designContext = buildDesignContext(manifest);
1231
1325
 
1232
1326
  return {
1233
1327
  mode: "bootstrap",
@@ -1259,6 +1353,8 @@ function buildBootstrapPrepareResult(cwd: string, target: string, includeContent
1259
1353
  items: [],
1260
1354
  ...(sharedLayerInfos.length > 0 ? { shared_layers: sharedLayerInfos } : {}),
1261
1355
  ...(includeContents ? { spec_contents: readAllSpecContents(projectDir) } : {}),
1356
+ ...(antiPatterns ? { anti_patterns: antiPatterns } : {}),
1357
+ ...(designContext ? { design_context: designContext } : {}),
1262
1358
  bootstrap: {
1263
1359
  output_exists: existsSync(outputDir),
1264
1360
  generation_ready: missingDecisions.length === 0 && backendContextReady && !pendingUserConfirmation,
@@ -107,6 +107,11 @@
107
107
  "may_handle": {
108
108
  "type": "array",
109
109
  "items": { "type": "string" }
110
+ },
111
+ "must_avoid": {
112
+ "type": "array",
113
+ "items": { "type": "string" },
114
+ "description": "Anti-patterns for AI generators. Strings may start with [web], [ios], or [android] to scope to a platform. Unmarked items apply to all platforms."
110
115
  }
111
116
  },
112
117
  "additionalProperties": false