Skip to content

Commit 2300067

Browse files
garrytanclaude
andauthored
feat: UX behavioral foundations + ux-audit command (v0.17.0.0) (garrytan#1000)
* feat: UX behavioral foundations — Krug's usability principles as shared design infrastructure Add UX_PRINCIPLES resolver distilling Steve Krug's "Don't Make Me Think" into actionable guidance for AI agents. Injected into all 4 design skills as a shared behavioral foundation complementing the existing visual checklist (WHAT to check) and cognitive patterns (HOW designers see) with HOW USERS ACTUALLY BEHAVE. Methodology rewire: 6 Krug usability tests woven into existing design-review phases — Trunk Test, 3-Second Scan, Page Area Test, Happy Talk Detection with word count metric, Mindless Choice Audit, Goodwill Reservoir tracking with visual dashboard. First-person narration mode for design-review output with anti-slop guardrail. Hard rules: 4 Krug always/never rules in DESIGN_HARD_RULES (placeholder-as-label, floating headings, visited link distinction, minimum type size). Krug, Redish, Jarrett added to plan-design-review references. Token ceiling: gen-skill-docs.ts warns if any SKILL.md exceeds 100KB (~25K tokens). Documented in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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> * docs: update project documentation for v0.17.0.0 ARCHITECTURE.md: added {{UX_PRINCIPLES}} resolver to placeholder table. VERSION: bumped to 0.17.0.0 for UX behavioral foundations release. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.17.0.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: adversarial review fixes for ux-audit and heatmap Security: - Remove live form value extraction from ux-audit (leaked input field values) - Add ux-audit to PAGE_CONTENT_COMMANDS (untrusted content wrapping) Correctness: - Scope youAreHere selector to nav containers (was matching animation classes) - Validate heatmap JSON is a plain object (string/array/null produced garbage) - Use textContent instead of innerText for word count (avoids layout computation) - Remove dead url variable and unused LINK_CAP constant Found by Codex + Claude adversarial review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7e96fe2 commit 2300067

21 files changed

Lines changed: 836 additions & 6 deletions

File tree

ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ Templates contain the workflows, tips, and examples that require human judgment.
208208
| `{{CODEX_PLAN_REVIEW}}` | `gen-skill-docs.ts` | Optional cross-model plan review (Codex or Claude subagent fallback) for /plan-ceo-review and /plan-eng-review |
209209
| `{{DESIGN_SETUP}}` | `resolvers/design.ts` | Discovery pattern for `$D` design binary, mirrors `{{BROWSE_SETUP}}` |
210210
| `{{DESIGN_SHOTGUN_LOOP}}` | `resolvers/design.ts` | Shared comparison board feedback loop for /design-shotgun, /plan-design-review, /design-consultation |
211+
| `{{UX_PRINCIPLES}}` | `resolvers/design.ts` | User behavioral foundations (scanning, satisficing, goodwill reservoir, trunk test) for /design-html, /design-shotgun, /design-review, /plan-design-review |
211212

212213
This is structurally sound — if a command exists in code, it appears in docs. If it doesn't exist, it can't appear.
213214

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## [0.17.0.0] - 2026-04-14
4+
5+
### Added
6+
- **UX behavioral foundations.** Every design skill now thinks about how users actually behave, not just how the interface looks. A shared `{{UX_PRINCIPLES}}` resolver distills Steve Krug's "Don't Make Me Think" into actionable guidance: scanning behavior, satisficing, the goodwill reservoir, navigation wayfinding, and the trunk test. Injected into /design-html, /design-shotgun, /design-review, and /plan-design-review. Your design reviews now catch "this navigation is confusing" problems, not just "the contrast ratio is 4.3:1."
7+
- **6 usability tests woven into design-review.** The methodology now runs the Trunk Test (can you tell what site this is, what page you're on, and how to search?), 3-Second Scan (what do users see first?), Page Area Test (can you name each section's purpose?), Happy Talk Detection with word count (how much of this page is "blah blah blah"?), Mindless Choice Audit (does every click feel obvious?), and Goodwill Reservoir tracking with a visual dashboard (what depletes the user's patience at each step?).
8+
- **First-person narration mode.** Design review reports now read like a usability consultant watching someone use your site: "I'm looking at this page... my eye goes to the logo, then a wall of text I skip entirely. Wait, is that a button?" With anti-slop guardrail: if the agent can't name the specific element, it's generating platitudes.
9+
- **`$B ux-audit` command.** Standalone UX structural extraction. One command extracts site ID, navigation, headings, interactive elements, text blocks, and search presence as structured JSON. The agent applies the 6 usability tests to the data. Pure data extraction with element caps (50 headings, 100 links, 200 interactive, 50 text blocks).
10+
- **`snapshot -H` / `--heatmap` flag.** Color-coded overlay screenshots. Pass a JSON map of ref IDs to colors (`green`/`yellow`/`red`/`blue`/`orange`/`gray`) and get an annotated screenshot with per-element colored boxes. Color whitelist prevents CSS injection. Composable: any skill can use it.
11+
- **Token ceiling enforcement.** `gen-skill-docs` now warns if any generated SKILL.md exceeds 100KB (~25K tokens). Catches prompt bloat before it degrades agent performance.
12+
13+
### Changed
14+
- **Krug's always/never rules** added to the design hard rules: never placeholder-as-label, never floating headings, always visited link distinction, never sub-16px body text. These join the existing AI slop blacklist as mechanical checks.
15+
- **Plan-design-review references** now include Steve Krug, Ginny Redish (Letting Go of the Words), and Caroline Jarrett (Forms that Work) alongside Rams, Norman, and Nielsen.
16+
317
## [0.16.4.0] - 2026-04-13
418

519
### Added

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ SKILL.md files are **generated** from `.tmpl` templates. To update docs:
138138
To add a new browse command: add it to `browse/src/commands.ts` and rebuild.
139139
To add a snapshot flag: add it to `SNAPSHOT_FLAGS` in `browse/src/snapshot.ts` and rebuild.
140140

141+
**Token ceiling:** Generated SKILL.md files must stay under 100KB (~25K tokens).
142+
`gen-skill-docs` warns if any file exceeds this. If a skill template grows past the
143+
ceiling, consider extracting optional sections into separate resolvers that only
144+
inject when relevant, or making verbose evaluation rubrics more concise.
145+
141146
**Merge conflicts on SKILL.md files:** NEVER resolve conflicts on generated SKILL.md
142147
files by accepting either side. Instead: (1) resolve conflicts on the `.tmpl` templates
143148
and `scripts/gen-skill-docs.ts` (the sources of truth), (2) run `bun run gen:skill-docs`

SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
719719
-a --annotate Annotated screenshot with red overlay boxes and ref labels
720720
-o <path> --output Output path for annotated screenshot (default: <temp>/browse-annotated.png)
721721
-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.
722+
-H <json> --heatmap Color-coded overlay screenshot from JSON map: '{"@e1":"green","@e3":"red"}'. Valid colors: green, yellow, red, blue, orange, gray.
722723
```
723724

724725
All flags can be combined freely. `-o` only applies when `-a` is also used.
@@ -825,6 +826,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
825826
| `network [--clear]` | Network requests |
826827
| `perf` | Page load timings |
827828
| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage |
829+
| `ux-audit` | Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation. |
828830

829831
### Visual
830832
| Command | Description |

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.16.4.0
1+
0.17.0.0

browse/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,7 @@ The snapshot is your primary tool for understanding and interacting with pages.
587587
-a --annotate Annotated screenshot with red overlay boxes and ref labels
588588
-o <path> --output Output path for annotated screenshot (default: <temp>/browse-annotated.png)
589589
-C --cursor-interactive Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.
590+
-H <json> --heatmap Color-coded overlay screenshot from JSON map: '{"@e1":"green","@e3":"red"}'. Valid colors: green, yellow, red, blue, orange, gray.
590591
```
591592

592593
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
717718
| `network [--clear]` | Network requests |
718719
| `perf` | Page load timings |
719720
| `storage [set k v]` | Read all localStorage + sessionStorage as JSON, or set <key> <value> to write localStorage |
721+
| `ux-audit` | Extract page structure for UX behavioral analysis — site ID, nav, headings, text blocks, interactive elements. Returns JSON for agent interpretation. |
720722

721723
### Visual
722724
| Command | Description |

browse/src/commands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const META_COMMANDS = new Set([
4040
'watch',
4141
'state',
4242
'frame',
43+
'ux-audit',
4344
]);
4445

4546
export const ALL_COMMANDS = new Set([...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS]);
@@ -49,6 +50,7 @@ export const PAGE_CONTENT_COMMANDS = new Set([
4950
'text', 'html', 'links', 'forms', 'accessibility', 'attrs',
5051
'console', 'dialog',
5152
'media', 'data',
53+
'ux-audit',
5254
]);
5355

5456
/** Wrap output from untrusted-content commands with trust boundary markers */
@@ -146,6 +148,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
146148
'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style <sel> <prop> <value> | style --undo [N]' },
147149
'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' },
148150
'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]' },
151+
// UX Audit
152+
'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' },
149153
};
150154

151155
// Load-time validation: descriptions must cover exactly the command sets

browse/src/meta-commands.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,116 @@ export async function handleMetaCommand(
653653
return `Switched to frame: ${frame.url()}`;
654654
}
655655

656+
// ─── UX Audit ─────────────────────────────────────
657+
case 'ux-audit': {
658+
const page = bm.getPage();
659+
660+
// Extract page structure for UX behavioral analysis
661+
// Agent interprets the data and applies Krug's 6 usability tests
662+
// Uses textContent (not innerText) to avoid layout computation on large DOMs
663+
const data = await page.evaluate(() => {
664+
const HEADING_CAP = 50;
665+
const INTERACTIVE_CAP = 200;
666+
const TEXT_BLOCK_CAP = 50;
667+
668+
// Site ID: logo or brand element
669+
const logoEl = document.querySelector('[class*="logo"], [id*="logo"], header img, [aria-label*="home"], a[href="/"]');
670+
const siteId = logoEl ? {
671+
found: true,
672+
text: (logoEl.textContent || '').trim().slice(0, 100),
673+
tag: logoEl.tagName,
674+
alt: (logoEl as HTMLImageElement).alt || null,
675+
} : { found: false, text: null, tag: null, alt: null };
676+
677+
// Page name: main heading
678+
const h1 = document.querySelector('h1');
679+
const pageName = h1 ? {
680+
found: true,
681+
text: h1.textContent?.trim().slice(0, 200) || '',
682+
} : { found: false, text: null };
683+
684+
// Navigation: primary nav elements
685+
const navEls = document.querySelectorAll('nav, [role="navigation"]');
686+
const navItems: Array<{ text: string; links: number }> = [];
687+
navEls.forEach((nav, i) => {
688+
if (i >= 5) return;
689+
const links = nav.querySelectorAll('a');
690+
navItems.push({
691+
text: (nav.getAttribute('aria-label') || `nav-${i}`).slice(0, 50),
692+
links: links.length,
693+
});
694+
});
695+
696+
// "You are here" indicator: current/active nav items
697+
// Scoped to nav containers to avoid false positives from animation classes
698+
const activeNavItems = document.querySelectorAll('nav [aria-current], nav .active, nav .current, [role="navigation"] [aria-current], [role="navigation"] .active, [role="navigation"] .current');
699+
const youAreHere = Array.from(activeNavItems).slice(0, 5).map(el => ({
700+
text: (el.textContent || '').trim().slice(0, 50),
701+
tag: el.tagName,
702+
}));
703+
704+
// Search: search box presence
705+
const searchEl = document.querySelector('input[type="search"], [role="search"], input[name*="search"], input[placeholder*="search" i], input[aria-label*="search" i]');
706+
const search = { found: !!searchEl };
707+
708+
// Breadcrumbs
709+
const breadcrumbEl = document.querySelector('[aria-label*="breadcrumb" i], .breadcrumb, .breadcrumbs, [class*="breadcrumb"]');
710+
const breadcrumbs = breadcrumbEl ? {
711+
found: true,
712+
items: Array.from(breadcrumbEl.querySelectorAll('a, span, li')).slice(0, 10).map(el => (el.textContent || '').trim().slice(0, 30)),
713+
} : { found: false, items: [] };
714+
715+
// Headings: heading hierarchy
716+
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).slice(0, HEADING_CAP).map(h => ({
717+
tag: h.tagName,
718+
text: (h.textContent || '').trim().slice(0, 80),
719+
size: getComputedStyle(h).fontSize,
720+
}));
721+
722+
// Interactive elements: buttons, links, inputs
723+
const interactiveEls = Array.from(document.querySelectorAll('a, button, input, select, textarea, [role="button"], [tabindex]')).slice(0, INTERACTIVE_CAP);
724+
const interactive = interactiveEls.map(el => {
725+
const rect = el.getBoundingClientRect();
726+
return {
727+
tag: el.tagName,
728+
text: (el.textContent || (el as HTMLInputElement).placeholder || '').trim().slice(0, 50),
729+
type: (el as HTMLInputElement).type || null,
730+
role: el.getAttribute('role'),
731+
w: Math.round(rect.width),
732+
h: Math.round(rect.height),
733+
visible: rect.width > 0 && rect.height > 0,
734+
};
735+
}).filter(el => el.visible);
736+
737+
// Text blocks: paragraphs and large text areas
738+
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 => ({
739+
text: (el.textContent || '').trim().slice(0, 200),
740+
wordCount: (el.textContent || '').trim().split(/\s+/).filter(Boolean).length,
741+
}));
742+
743+
// Total visible text word count (textContent avoids layout computation)
744+
const bodyText = (document.body?.textContent || '').trim();
745+
const totalWords = bodyText.split(/\s+/).filter(Boolean).length;
746+
747+
return {
748+
url: window.location.href,
749+
title: document.title,
750+
siteId,
751+
pageName,
752+
navigation: navItems,
753+
youAreHere,
754+
search,
755+
breadcrumbs,
756+
headings,
757+
interactive,
758+
textBlocks,
759+
totalWords,
760+
};
761+
});
762+
763+
return JSON.stringify(data, null, 2);
764+
}
765+
656766
default:
657767
throw new Error(`Unknown meta command: ${command}`);
658768
}

browse/src/snapshot.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface SnapshotOptions {
3939
annotate?: boolean; // -a / --annotate: annotated screenshot
4040
outputPath?: string; // -o / --output: path for annotated screenshot
4141
cursorInteractive?: boolean; // -C / --cursor-interactive: scan cursor:pointer etc.
42+
heatmap?: string; // -H / --heatmap: JSON color map for ref overlays
4243
}
4344

4445
/**
@@ -64,6 +65,7 @@ export const SNAPSHOT_FLAGS: Array<{
6465
{ short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
6566
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
6667
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick). Auto-enabled when -i is used.', optionKey: 'cursorInteractive' },
68+
{ 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' },
6769
];
6870

6971
interface ParsedNode {
@@ -435,6 +437,124 @@ export async function handleSnapshot(
435437
}
436438
}
437439

440+
// ─── Heatmap mode (-H) ──────────────────────────────────────
441+
if (opts.heatmap) {
442+
const heatmapPath = opts.outputPath || `${TEMP_DIR}/browse-heatmap.png`;
443+
// Validate output path
444+
{
445+
const nodePath = require('path') as typeof import('path');
446+
const nodeFs = require('fs') as typeof import('fs');
447+
const absolute = nodePath.resolve(heatmapPath);
448+
const safeDirs = [TEMP_DIR, process.cwd()].map((d: string) => {
449+
try { return nodeFs.realpathSync(d); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; return d; }
450+
});
451+
let realPath: string;
452+
try {
453+
realPath = nodeFs.realpathSync(absolute);
454+
} catch (err: any) {
455+
if (err.code === 'ENOENT') {
456+
try {
457+
const dir = nodeFs.realpathSync(nodePath.dirname(absolute));
458+
realPath = nodePath.join(dir, nodePath.basename(absolute));
459+
} catch (err2: any) {
460+
if (err2?.code !== 'ENOENT') throw err2;
461+
realPath = absolute;
462+
}
463+
} else {
464+
throw new Error(`Cannot resolve real path: ${heatmapPath} (${err.code})`);
465+
}
466+
}
467+
if (!safeDirs.some((dir: string) => isPathWithin(realPath, dir))) {
468+
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
469+
}
470+
}
471+
472+
// Parse and validate color map
473+
const VALID_COLORS = new Set(['green', 'yellow', 'red', 'blue', 'orange', 'gray']);
474+
const COLOR_MAP: Record<string, { border: string; bg: string }> = {
475+
green: { border: '#00b400', bg: 'rgba(0,180,0,0.15)' },
476+
yellow: { border: '#ffb400', bg: 'rgba(255,180,0,0.15)' },
477+
red: { border: '#ff0000', bg: 'rgba(255,0,0,0.15)' },
478+
blue: { border: '#0066ff', bg: 'rgba(0,102,255,0.15)' },
479+
orange: { border: '#ff6600', bg: 'rgba(255,102,0,0.15)' },
480+
gray: { border: '#888888', bg: 'rgba(136,136,136,0.15)' },
481+
};
482+
483+
let colorAssignments: Record<string, string>;
484+
try {
485+
const parsed = JSON.parse(opts.heatmap);
486+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
487+
throw new Error('not an object');
488+
}
489+
colorAssignments = parsed;
490+
} catch {
491+
throw new Error('Invalid heatmap JSON. Expected object: \'{"@e1":"green","@e3":"red"}\'');
492+
}
493+
494+
// Validate colors
495+
for (const [ref, color] of Object.entries(colorAssignments)) {
496+
if (!VALID_COLORS.has(color)) {
497+
throw new Error(`Invalid heatmap color "${color}" for ${ref}. Valid: ${[...VALID_COLORS].join(', ')}`);
498+
}
499+
}
500+
501+
try {
502+
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number }; color: string }> = [];
503+
for (const [refKey, color] of Object.entries(colorAssignments)) {
504+
const cleanRef = refKey.startsWith('@') ? refKey.slice(1) : refKey;
505+
const entry = refMap.get(cleanRef);
506+
if (!entry) continue; // Skip refs not found on page
507+
try {
508+
const box = await entry.locator.boundingBox({ timeout: 1000 });
509+
if (box) {
510+
const colors = COLOR_MAP[color] || COLOR_MAP.gray;
511+
boxes.push({ ref: `@${cleanRef}`, box, color: JSON.stringify(colors) });
512+
}
513+
} catch {
514+
// Element may be offscreen or hidden — skip
515+
}
516+
}
517+
518+
await page.evaluate((boxes) => {
519+
for (const { ref, box, color } of boxes) {
520+
const colors = JSON.parse(color);
521+
const overlay = document.createElement('div');
522+
overlay.className = '__browse_heatmap__';
523+
overlay.style.cssText = `
524+
position: absolute; top: ${box.y}px; left: ${box.x}px;
525+
width: ${box.width}px; height: ${box.height}px;
526+
border: 2px solid ${colors.border}; background: ${colors.bg};
527+
pointer-events: none; z-index: 99999;
528+
font-size: 10px; color: ${colors.border}; font-weight: bold;
529+
`;
530+
const label = document.createElement('span');
531+
label.textContent = ref;
532+
label.style.cssText = `position: absolute; top: -14px; left: 0; background: ${colors.border}; color: white; padding: 0 3px; font-size: 10px;`;
533+
overlay.appendChild(label);
534+
document.body.appendChild(overlay);
535+
}
536+
}, boxes);
537+
538+
await page.screenshot({ path: heatmapPath, fullPage: true });
539+
540+
// Remove heatmap overlays
541+
await page.evaluate(() => {
542+
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
543+
});
544+
545+
output.push('');
546+
output.push(`[heatmap screenshot: ${heatmapPath}]`);
547+
} catch (err: any) {
548+
// Cleanup on failure
549+
try {
550+
await page.evaluate(() => {
551+
document.querySelectorAll('.__browse_heatmap__').forEach(el => el.remove());
552+
});
553+
} catch {}
554+
if (!err?.message?.includes('closed') && !err?.message?.includes('Target') && !err?.message?.includes('Execution context') && !err?.message?.includes('screenshot')) throw err;
555+
}
556+
}
557+
438558
// ─── Diff mode (-D) ───────────────────────────────────────
439559
if (opts.diff) {
440560
const lastSnapshot = session.getLastSnapshot();

0 commit comments

Comments
 (0)