mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-12 01:00:21 +02:00
feat: $B ux-audit command + snapshot --heatmap flag
New browse meta-command: ux-audit extracts page structure (site ID, navigation, headings, interactive elements, text blocks) as structured JSON for agent-side UX behavioral analysis. Pure data extraction — the agent applies the 6 usability tests and makes judgment calls. Element caps: 50 headings, 100 links, 200 interactive, 50 text blocks. New snapshot flag: -H/--heatmap accepts a JSON color map mapping ref IDs to colors (green/yellow/red/blue/orange/gray). Extends existing snapshot -a annotation system with per-ref colors instead of hardcoded red. Color whitelist validation prevents CSS injection. Composable — any skill can use it. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ab7c315134
commit
1a696f1475
@@ -587,6 +587,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
|
|||||||
-a --annotate Annotated screenshot with red overlay boxes and ref labels
|
-a --annotate Annotated screenshot with red overlay boxes and ref labels
|
||||||
-o <path> --output Output path for annotated screenshot (default: <temp>/browse-annotated.png)
|
-o <path> --output Output path for annotated screenshot (default: <temp>/browse-annotated.png)
|
||||||
-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.
|
-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.
|
||||||
|
-H <json> --heatmap Color-coded overlay screenshot from JSON map: '{"@e1":"green","@e3":"red"}'. Valid colors: green, yellow, red, blue, orange, gray.
|
||||||
```
|
```
|
||||||
|
|
||||||
All flags can be combined freely. `-o` only applies when `-a` is also used.
|
All flags can be combined freely. `-o` only applies when `-a` is also used.
|
||||||
@@ -717,6 +718,7 @@ $B prettyscreenshot --cleanup --scroll-to ".pricing" --width 1440 ~/Desktop/hero
|
|||||||
| `network [--clear]` | Network requests |
|
| `network [--clear]` | Network requests |
|
||||||
| `perf` | Page load timings |
|
| `perf` | Page load timings |
|
||||||
| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage |
|
| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage |
|
||||||
|
| `ux-audit` | Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation. |
|
||||||
|
|
||||||
### Visual
|
### Visual
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const META_COMMANDS = new Set([
|
|||||||
'watch',
|
'watch',
|
||||||
'state',
|
'state',
|
||||||
'frame',
|
'frame',
|
||||||
|
'ux-audit',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
|
||||||
@@ -146,6 +147,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
|||||||
'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style <sel> <prop> <value> | style --undo [N]' },
|
'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style <sel> <prop> <value> | style --undo [N]' },
|
||||||
'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' },
|
'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' },
|
||||||
'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' },
|
'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' },
|
||||||
|
// UX Audit
|
||||||
|
'ux-audit': { category: 'Inspection', description: 'Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation.', usage: 'ux-audit' },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load-time validation: descriptions must cover exactly the command sets
|
// Load-time validation: descriptions must cover exactly the command sets
|
||||||
|
|||||||
@@ -653,6 +653,116 @@ export async function handleMetaCommand(
|
|||||||
return `Switched to frame: ${frame.url()}`;
|
return `Switched to frame: ${frame.url()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── UX Audit ─────────────────────────────────────
|
||||||
|
case 'ux-audit': {
|
||||||
|
const page = bm.getPage();
|
||||||
|
const url = page.url();
|
||||||
|
|
||||||
|
// Extract page structure for UX behavioral analysis
|
||||||
|
// Agent interprets the data and applies Krug's 6 usability tests
|
||||||
|
const data = await page.evaluate(() => {
|
||||||
|
const HEADING_CAP = 50;
|
||||||
|
const LINK_CAP = 100;
|
||||||
|
const INTERACTIVE_CAP = 200;
|
||||||
|
const TEXT_BLOCK_CAP = 50;
|
||||||
|
|
||||||
|
// Site ID: logo or brand element
|
||||||
|
const logoEl = document.querySelector('[class*="logo"], [id*="logo"], header img, [aria-label*="home"], a[href="/"]');
|
||||||
|
const siteId = logoEl ? {
|
||||||
|
found: true,
|
||||||
|
text: (logoEl.textContent || '').trim().slice(0, 100),
|
||||||
|
tag: logoEl.tagName,
|
||||||
|
alt: (logoEl as HTMLImageElement).alt || null,
|
||||||
|
} : { found: false, text: null, tag: null, alt: null };
|
||||||
|
|
||||||
|
// Page name: main heading
|
||||||
|
const h1 = document.querySelector('h1');
|
||||||
|
const pageName = h1 ? {
|
||||||
|
found: true,
|
||||||
|
text: h1.textContent?.trim().slice(0, 200) || '',
|
||||||
|
} : { found: false, text: null };
|
||||||
|
|
||||||
|
// Navigation: primary nav elements
|
||||||
|
const navEls = document.querySelectorAll('nav, [role="navigation"]');
|
||||||
|
const navItems: Array<{ text: string; links: number }> = [];
|
||||||
|
navEls.forEach((nav, i) => {
|
||||||
|
if (i >= 5) return;
|
||||||
|
const links = nav.querySelectorAll('a');
|
||||||
|
navItems.push({
|
||||||
|
text: (nav.getAttribute('aria-label') || `nav-${i}`).slice(0, 50),
|
||||||
|
links: links.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// "You are here" indicator: current/active nav items
|
||||||
|
const activeNavItems = document.querySelectorAll('[aria-current], .active, .current, [class*="active"], [class*="current"]');
|
||||||
|
const youAreHere = Array.from(activeNavItems).slice(0, 5).map(el => ({
|
||||||
|
text: (el.textContent || '').trim().slice(0, 50),
|
||||||
|
tag: el.tagName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Search: search box presence
|
||||||
|
const searchEl = document.querySelector('input[type="search"], [role="search"], input[name*="search"], input[placeholder*="search" i], input[aria-label*="search" i]');
|
||||||
|
const search = { found: !!searchEl };
|
||||||
|
|
||||||
|
// Breadcrumbs
|
||||||
|
const breadcrumbEl = document.querySelector('[aria-label*="breadcrumb" i], .breadcrumb, .breadcrumbs, [class*="breadcrumb"]');
|
||||||
|
const breadcrumbs = breadcrumbEl ? {
|
||||||
|
found: true,
|
||||||
|
items: Array.from(breadcrumbEl.querySelectorAll('a, span, li')).slice(0, 10).map(el => (el.textContent || '').trim().slice(0, 30)),
|
||||||
|
} : { found: false, items: [] };
|
||||||
|
|
||||||
|
// Headings: heading hierarchy
|
||||||
|
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).slice(0, HEADING_CAP).map(h => ({
|
||||||
|
tag: h.tagName,
|
||||||
|
text: (h.textContent || '').trim().slice(0, 80),
|
||||||
|
size: getComputedStyle(h).fontSize,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Interactive elements: buttons, links, inputs
|
||||||
|
const interactiveEls = Array.from(document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]')).slice(0, INTERACTIVE_CAP);
|
||||||
|
const interactive = interactiveEls.map(el => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
tag: el.tagName,
|
||||||
|
text: (el.textContent || (el as HTMLInputElement).placeholder || (el as HTMLInputElement).value || '').trim().slice(0, 50),
|
||||||
|
type: (el as HTMLInputElement).type || null,
|
||||||
|
role: el.getAttribute('role'),
|
||||||
|
w: Math.round(rect.width),
|
||||||
|
h: Math.round(rect.height),
|
||||||
|
visible: rect.width > 0 && rect.height > 0,
|
||||||
|
};
|
||||||
|
}).filter(el => el.visible);
|
||||||
|
|
||||||
|
// Text blocks: paragraphs and large text areas
|
||||||
|
const textBlocks = Array.from(document.querySelectorAll('p, [class*="description"], [class*="intro"], [class*="welcome"], [class*="hero"] p, main p')).slice(0, TEXT_BLOCK_CAP).map(el => ({
|
||||||
|
text: (el.textContent || '').trim().slice(0, 200),
|
||||||
|
wordCount: (el.textContent || '').trim().split(/\s+/).filter(Boolean).length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Total visible text word count
|
||||||
|
const bodyText = (document.body?.innerText || '').trim();
|
||||||
|
const totalWords = bodyText.split(/\s+/).filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title,
|
||||||
|
siteId,
|
||||||
|
pageName,
|
||||||
|
navigation: navItems,
|
||||||
|
youAreHere,
|
||||||
|
search,
|
||||||
|
breadcrumbs,
|
||||||
|
headings,
|
||||||
|
interactive,
|
||||||
|
textBlocks,
|
||||||
|
totalWords,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown meta command: ${command}`);
|
throw new Error(`Unknown meta command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface SnapshotOptions {
|
|||||||
annotate?: boolean; // -a / --annotate: annotated screenshot
|
annotate?: boolean; // -a / --annotate: annotated screenshot
|
||||||
outputPath?: string; // -o / --output: path for annotated screenshot
|
outputPath?: string; // -o / --output: path for annotated screenshot
|
||||||
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
|
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
|
||||||
|
heatmap?: string; // -H / --heatmap: JSON color map for ref overlays
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +65,7 @@ export const SNAPSHOT_FLAGS: Array<{
|
|||||||
{ short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
|
{ short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
|
||||||
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
|
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
|
||||||
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.', optionKey: 'cursorInteractive' },
|
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.', optionKey: 'cursorInteractive' },
|
||||||
|
{ short: '-H', long: '--heatmap', description: 'Color-coded overlay screenshot from JSON map: \'{"@e1":"green","@e3":"red"}\'. Valid colors: green, yellow, red, blue, orange, gray.', takesValue: true, valueHint: '<json>', optionKey: 'heatmap' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ParsedNode {
|
interface ParsedNode {
|
||||||
@@ -435,6 +437,120 @@ export async function handleSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Heatmap mode (-H) ──────────────────────────────────────
|
||||||
|
if (opts.heatmap) {
|
||||||
|
const heatmapPath = opts.outputPath || `${TEMP_DIR}/browse-heatmap.png`;
|
||||||
|
// Validate output path
|
||||||
|
{
|
||||||
|
const nodePath = require('path') as typeof import('path');
|
||||||
|
const nodeFs = require('fs') as typeof import('fs');
|
||||||
|
const absolute = nodePath.resolve(heatmapPath);
|
||||||
|
const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
|
||||||
|
try { return nodeFs.realpathSync(d); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; return d; }
|
||||||
|
});
|
||||||
|
let realPath: string;
|
||||||
|
try {
|
||||||
|
realPath = nodeFs.realpathSync(absolute);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
try {
|
||||||
|
const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
|
||||||
|
realPath = nodePath.join(dir, nodePath.basename(absolute));
|
||||||
|
} catch (err2: any) {
|
||||||
|
if (err2?.code !== 'ENOENT') throw err2;
|
||||||
|
realPath = absolute;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Cannot resolve real path: ${heatmapPath} (${err.code})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
|
||||||
|
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate color map
|
||||||
|
const VALID_COLORS = new Set(['green', 'yellow', 'red', 'blue', 'orange', 'gray']);
|
||||||
|
const COLOR_MAP: Record<string, { border: string; bg: string }> = {
|
||||||
|
green: { border: '#00b400', bg: 'rgba(0,180,0,0.15)' },
|
||||||
|
yellow: { border: '#ffb400', bg: 'rgba(255,180,0,0.15)' },
|
||||||
|
red: { border: '#ff0000', bg: 'rgba(255,0,0,0.15)' },
|
||||||
|
blue: { border: '#0066ff', bg: 'rgba(0,102,255,0.15)' },
|
||||||
|
orange: { border: '#ff6600', bg: 'rgba(255,102,0,0.15)' },
|
||||||
|
gray: { border: '#888888', bg: 'rgba(136,136,136,0.15)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
let colorAssignments: Record<string, string>;
|
||||||
|
try {
|
||||||
|
colorAssignments = JSON.parse(opts.heatmap);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid heatmap JSON. Expected: \'{"@e1":"green","@e3":"red"}\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate colors
|
||||||
|
for (const [ref, color] of Object.entries(colorAssignments)) {
|
||||||
|
if (!VALID_COLORS.has(color)) {
|
||||||
|
throw new Error(`Invalid heatmap color "${color}" for ${ref}. Valid: ${[...VALID_COLORS].join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number }; color: string }> = [];
|
||||||
|
for (const [refKey, color] of Object.entries(colorAssignments)) {
|
||||||
|
const cleanRef = refKey.startsWith('@') ? refKey.slice(1) : refKey;
|
||||||
|
const entry = refMap.get(cleanRef);
|
||||||
|
if (!entry) continue; // Skip refs not found on page
|
||||||
|
try {
|
||||||
|
const box = await entry.locator.boundingBox({ timeout: 1000 });
|
||||||
|
if (box) {
|
||||||
|
const colors = COLOR_MAP[color] || COLOR_MAP.gray;
|
||||||
|
boxes.push({ ref: `@${cleanRef}`, box, color: JSON.stringify(colors) });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Element may be offscreen or hidden — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.evaluate((boxes) => {
|
||||||
|
for (const { ref, box, color } of boxes) {
|
||||||
|
const colors = JSON.parse(color);
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = '__browse_heatmap__';
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position: absolute; top: ${box.y}px; left: ${box.x}px;
|
||||||
|
width: ${box.width}px; height: ${box.height}px;
|
||||||
|
border: 2px solid ${colors.border}; background: ${colors.bg};
|
||||||
|
pointer-events: none; z-index: 99999;
|
||||||
|
font-size: 10px; color: ${colors.border}; font-weight: bold;
|
||||||
|
`;
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = ref;
|
||||||
|
label.style.cssText = `position: absolute; top: -14px; left: 0; background: ${colors.border}; color: white; padding: 0 3px; font-size: 10px;`;
|
||||||
|
overlay.appendChild(label);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
}, boxes);
|
||||||
|
|
||||||
|
await page.screenshot({ path: heatmapPath, fullPage: true });
|
||||||
|
|
||||||
|
// Remove heatmap overlays
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
output.push('');
|
||||||
|
output.push(`[heatmap screenshot: ${heatmapPath}]`);
|
||||||
|
} catch (err: any) {
|
||||||
|
// Cleanup on failure
|
||||||
|
try {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context') && !err?.message?.includes('screenshot')) throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Diff mode (-D) ───────────────────────────────────────
|
// ─── Diff mode (-D) ───────────────────────────────────────
|
||||||
if (opts.diff) {
|
if (opts.diff) {
|
||||||
const lastSnapshot = session.getLastSnapshot();
|
const lastSnapshot = session.getLastSnapshot();
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ describe('Command registry consistency', () => {
|
|||||||
const validKeys = new Set([
|
const validKeys = new Set([
|
||||||
'interactive', 'compact', 'depth', 'selector',
|
'interactive', 'compact', 'depth', 'selector',
|
||||||
'diff', 'annotate', 'outputPath', 'cursorInteractive',
|
'diff', 'annotate', 'outputPath', 'cursorInteractive',
|
||||||
|
'heatmap',
|
||||||
]);
|
]);
|
||||||
for (const flag of SNAPSHOT_FLAGS) {
|
for (const flag of SNAPSHOT_FLAGS) {
|
||||||
expect(validKeys.has(flag.optionKey)).toBe(true);
|
expect(validKeys.has(flag.optionKey)).toBe(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user