@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.
- package/extensions/index.ts +155 -41
- package/package.json +1 -1
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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:
|
|
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
|
-
//
|
|
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 (${
|
|
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:
|
|
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?",
|