@elyracode/design-tools 0.9.13 → 0.9.15

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 (2) hide show
  1. package/extensions/index.ts +155 -41
  2. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ExtensionAPI } from "@elyracode/coding-agent";
@@ -157,6 +157,18 @@ export default function (elyra: ExtensionAPI): void {
157
157
  "Use mobile viewport (375x812) (default: false)",
158
158
  }),
159
159
  ),
160
+ selector: Type.Optional(
161
+ Type.String({
162
+ description:
163
+ "CSS selector to screenshot a specific element instead of the viewport (e.g., '#app', '.card'). Useful for focusing on the component you just changed.",
164
+ }),
165
+ ),
166
+ full_page: Type.Optional(
167
+ Type.Boolean({
168
+ description:
169
+ "Capture the full scrollable page instead of just the viewport (default: false)",
170
+ }),
171
+ ),
160
172
  }),
161
173
  execute: async (_toolCallId, params) => {
162
174
  try {
@@ -164,61 +176,63 @@ export default function (elyra: ExtensionAPI): void {
164
176
  mkdirSync(PREVIEW_DIR, { recursive: true });
165
177
  }
166
178
 
167
- const screenshotPath = join(
168
- PREVIEW_DIR,
169
- `screenshot-${Date.now()}.png`,
179
+ const stamp = Date.now();
180
+ const screenshotPath = join(PREVIEW_DIR, `screenshot-${stamp}.png`);
181
+ const consolePath = join(PREVIEW_DIR, `console-${stamp}.json`);
182
+ const configPath = join(PREVIEW_DIR, `capture-${stamp}.json`);
183
+ const width = params.mobile ? 375 : (params.width ?? 1280);
184
+ const height = params.mobile ? 812 : (params.height ?? 800);
185
+
186
+ // Pass all parameters via a JSON config file rather than string
187
+ // interpolation, so a hostile URL/selector can't inject into the script.
188
+ writeFileSync(
189
+ configPath,
190
+ JSON.stringify({
191
+ url: params.url,
192
+ width,
193
+ height,
194
+ selector: params.selector ?? null,
195
+ fullPage: params.full_page ?? false,
196
+ screenshotPath,
197
+ consolePath,
198
+ }),
199
+ "utf-8",
170
200
  );
171
- const width = params.mobile
172
- ? 375
173
- : (params.width ?? 1280);
174
- const height = params.mobile
175
- ? 812
176
- : (params.height ?? 800);
177
-
178
- // Try Puppeteer first (if installed globally or in project)
179
- let captured = false;
180
201
 
202
+ let captured = false;
181
203
  try {
182
- // Write a small Node script that uses puppeteer
183
204
  const scriptPath = join(PREVIEW_DIR, "capture.mjs");
184
205
  writeFileSync(
185
206
  scriptPath,
186
207
  `
208
+ import { readFileSync, writeFileSync } from 'node:fs';
187
209
  import puppeteer from 'puppeteer';
210
+ const cfg = JSON.parse(readFileSync(process.argv[2], 'utf-8'));
188
211
  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
189
212
  const page = await browser.newPage();
190
- await page.setViewport({ width: ${width}, height: ${height} });
191
- await page.goto('${params.url}', { waitUntil: 'networkidle2', timeout: 15000 });
192
- await page.screenshot({ path: '${screenshotPath}', fullPage: false });
213
+ const logs = [];
214
+ page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning') logs.push(m.type() + ': ' + m.text()); });
215
+ page.on('pageerror', (e) => logs.push('pageerror: ' + e.message));
216
+ page.on('requestfailed', (r) => logs.push('requestfailed: ' + r.url() + ' ' + (r.failure()?.errorText ?? '')));
217
+ await page.setViewport({ width: cfg.width, height: cfg.height });
218
+ await page.goto(cfg.url, { waitUntil: 'networkidle2', timeout: 15000 });
219
+ const target = cfg.selector ? await page.$(cfg.selector) : null;
220
+ if (cfg.selector && !target) throw new Error('Selector not found: ' + cfg.selector);
221
+ if (target) await target.screenshot({ path: cfg.screenshotPath });
222
+ else await page.screenshot({ path: cfg.screenshotPath, fullPage: cfg.fullPage });
223
+ writeFileSync(cfg.consolePath, JSON.stringify(logs));
193
224
  await browser.close();
194
225
  `,
195
226
  "utf-8",
196
227
  );
197
228
 
198
- execSync(`node "${scriptPath}"`, {
229
+ execSync(`node "${scriptPath}" "${configPath}"`, {
199
230
  timeout: 30000,
200
231
  stdio: "pipe",
201
232
  });
202
233
  captured = existsSync(screenshotPath);
203
234
  } catch {
204
- // Puppeteer not available
205
- }
206
-
207
- // Fallback: macOS screencapture (if the URL is a local file)
208
- if (
209
- !captured &&
210
- process.platform === "darwin" &&
211
- params.url.startsWith("file://")
212
- ) {
213
- try {
214
- execSync(
215
- `screencapture -x -C "${screenshotPath}"`,
216
- { timeout: 10000 },
217
- );
218
- captured = existsSync(screenshotPath);
219
- } catch {
220
- // screencapture failed
221
- }
235
+ // Puppeteer not available or capture failed
222
236
  }
223
237
 
224
238
  if (!captured) {
@@ -226,14 +240,32 @@ await browser.close();
226
240
  content: [
227
241
  {
228
242
  type: "text",
229
- text: `Could not capture screenshot. Install Puppeteer for automated screenshots:\n\nnpm install -g puppeteer\n\nOr take a manual screenshot and paste it into the chat.`,
243
+ text: "Could not capture screenshot. This tool needs a headless browser: install Puppeteer (npm install -g puppeteer) and ensure the URL is reachable (start your dev server first). You can also paste a screenshot into the chat manually.",
230
244
  },
231
245
  ],
232
246
  details: {},
233
247
  };
234
248
  }
235
249
 
236
- // Read the screenshot and return as image content
250
+ // Collect any captured browser errors/warnings.
251
+ let consoleNote = "";
252
+ try {
253
+ if (existsSync(consolePath)) {
254
+ const logs = JSON.parse(readFileSync(consolePath, "utf-8")) as string[];
255
+ if (logs.length > 0) {
256
+ consoleNote = `\n\nBrowser console (${logs.length} error/warning):\n${logs.slice(0, 20).join("\n")}`;
257
+ }
258
+ }
259
+ } catch {
260
+ // ignore console-read failures
261
+ }
262
+
263
+ const scope = params.selector
264
+ ? `element "${params.selector}"`
265
+ : params.full_page
266
+ ? "full page"
267
+ : `viewport ${width}x${height}`;
268
+
237
269
  const imageData = readFileSync(screenshotPath);
238
270
  const base64 = imageData.toString("base64");
239
271
 
@@ -246,10 +278,10 @@ await browser.close();
246
278
  },
247
279
  {
248
280
  type: "text",
249
- text: `Screenshot captured (${width}x${height}): ${screenshotPath}\nAnalyze the screenshot for visual issues: alignment, spacing, color contrast, responsive layout, text readability.`,
281
+ text: `Screenshot captured (${scope}): ${screenshotPath}\nAnalyze it for visual issues: alignment, spacing, color contrast, responsive layout, text readability. A visible bug is often a JS error — check the console output below.${consoleNote}`,
250
282
  },
251
283
  ],
252
- details: { path: screenshotPath, width, height },
284
+ details: { path: screenshotPath, width, height, selector: params.selector ?? null },
253
285
  };
254
286
  } catch (error) {
255
287
  const msg =
@@ -267,7 +299,84 @@ await browser.close();
267
299
  },
268
300
  });
269
301
 
270
- // ── Tool 3: design_system_check ──
302
+ // ── Tool 3: design_diff ──
303
+ // Compare two screenshots and have the model describe the visual delta.
304
+ elyra.registerTool({
305
+ name: "design_diff",
306
+ label: "Design Diff",
307
+ description:
308
+ "Compare two screenshots (a before and an after) and analyze the visual difference. " +
309
+ "Returns both images so you can describe exactly what changed and flag unintended visual " +
310
+ "regressions after a code change. If no paths are given, uses the two most recent screenshots " +
311
+ "captured by screenshot_url.",
312
+ parameters: Type.Object({
313
+ before: Type.Optional(
314
+ Type.String({
315
+ description: "Path to the 'before' screenshot (default: second-most-recent capture)",
316
+ }),
317
+ ),
318
+ after: Type.Optional(
319
+ Type.String({
320
+ description: "Path to the 'after' screenshot (default: most-recent capture)",
321
+ }),
322
+ ),
323
+ }),
324
+ execute: async (_toolCallId, params) => {
325
+ try {
326
+ let beforePath = params.before;
327
+ let afterPath = params.after;
328
+
329
+ // Fall back to the two most recent screenshots in the preview dir.
330
+ if (!beforePath || !afterPath) {
331
+ const shots = existsSync(PREVIEW_DIR)
332
+ ? readdirSync(PREVIEW_DIR)
333
+ .filter((f) => f.startsWith("screenshot-") && f.endsWith(".png"))
334
+ .map((f) => join(PREVIEW_DIR, f))
335
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)
336
+ : [];
337
+ afterPath = afterPath ?? shots[0];
338
+ beforePath = beforePath ?? shots[1];
339
+ }
340
+
341
+ if (!beforePath || !afterPath || !existsSync(beforePath) || !existsSync(afterPath)) {
342
+ return {
343
+ content: [
344
+ {
345
+ type: "text",
346
+ text: "Need two screenshots to diff. Capture a 'before' with screenshot_url, make your change, capture an 'after', then call design_diff again (or pass explicit before/after paths).",
347
+ },
348
+ ],
349
+ details: {},
350
+ };
351
+ }
352
+
353
+ const beforeB64 = readFileSync(beforePath).toString("base64");
354
+ const afterB64 = readFileSync(afterPath).toString("base64");
355
+
356
+ return {
357
+ content: [
358
+ { type: "text", text: `BEFORE (${beforePath}):` },
359
+ { type: "image", data: beforeB64, mimeType: "image/png" },
360
+ { type: "text", text: `AFTER (${afterPath}):` },
361
+ { type: "image", data: afterB64, mimeType: "image/png" },
362
+ {
363
+ type: "text",
364
+ text: "Compare the two images. Describe what changed visually (layout, spacing, color, typography, added/removed elements). Flag anything that looks like an unintended regression versus an intended change.",
365
+ },
366
+ ],
367
+ details: { before: beforePath, after: afterPath },
368
+ };
369
+ } catch (error) {
370
+ const msg = error instanceof Error ? error.message : String(error);
371
+ return {
372
+ content: [{ type: "text", text: `Design diff failed: ${msg}` }],
373
+ details: {},
374
+ };
375
+ }
376
+ },
377
+ });
378
+
379
+ // ── Tool 4: design_system_check ──
271
380
  // Analyze Tailwind classes for consistency
272
381
  elyra.registerTool({
273
382
  name: "design_system_check",
@@ -453,6 +562,7 @@ await browser.close();
453
562
  const options = [
454
563
  "Preview -- render HTML/Tailwind in the browser",
455
564
  "Screenshot -- capture a web page for visual QA",
565
+ "Diff -- compare before/after screenshots",
456
566
  "Design Check -- analyze Tailwind classes for consistency",
457
567
  ];
458
568
 
@@ -470,6 +580,10 @@ await browser.close();
470
580
  elyra.sendUserMessage(
471
581
  "I want to take a screenshot of a page for visual QA. What URL should I capture?",
472
582
  );
583
+ } else if (selected.startsWith("Diff")) {
584
+ elyra.sendUserMessage(
585
+ "I want to compare before/after screenshots of a UI change. Capture a before, make the change, capture an after, then diff them.",
586
+ );
473
587
  } else if (selected.startsWith("Design Check")) {
474
588
  elyra.sendUserMessage(
475
589
  "I want to check a file for Tailwind design consistency. Which file should I analyze?",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elyracode/design-tools",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Elyra extension for UI design -- live browser preview, screenshot capture, and visual QA",
5
5
  "type": "module",
6
6
  "keywords": [