mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 14:34:49 +02:00
fix: snapshot -i auto-detects dropdown/popover interactive elements (#845)
* fix: snapshot -i auto-detects dropdown/popover interactive elements - Auto-enable cursor-interactive scan (-C) when -i flag is used - Add floating container detection (portals, popovers, dropdowns) - Detects position:fixed/absolute with high z-index - Recognizes data-floating-ui-portal, data-radix-* attributes - Recognizes role=listbox, role=menu containers - Elements inside floating containers bypass the hasRole skip - Catches dropdown items missed by the accessibility tree - Role=option/menuitem elements in floating containers captured even without cursor:pointer/onclick - Tag floating container items with 'popover-child' reason - Include role name in @c ref reasons when present - Add dropdown.html test fixture - Add dropdown/popover detection test suite (6 tests) - Add test: -i alone includes cursor-interactive elements Fixes: Bookface autocomplete, Radix UI combobox, React portals, and similar dynamic dropdown patterns where ariaSnapshot() misses the floating content. * chore: bump version and changelog (v0.15.12.0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update snapshot -i/-C flag descriptions to mention auto-enable behavior * test: strengthen clickability test guard assertions The @c ref clickability test previously used if-guards that would silently pass when no Alice line was found in the snapshot output. Both Claude and Codex adversarial review flagged this as a test that could regress without CI noticing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: regenerate top-level SKILL.md with updated flag descriptions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: root <root@localhost> Co-authored-by: gstack <ship@gstack.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+41
-6
@@ -56,14 +56,14 @@ export const SNAPSHOT_FLAGS: Array<{
|
||||
valueHint?: string;
|
||||
optionKey: keyof SnapshotOptions;
|
||||
}> = [
|
||||
{ short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs', optionKey: 'interactive' },
|
||||
{ short: '-i', long: '--interactive', description: 'Interactive elements only (buttons, links, inputs) with @e refs. Also auto-enables cursor-interactive scan (-C) to capture dropdowns and popovers.', optionKey: 'interactive' },
|
||||
{ short: '-c', long: '--compact', description: 'Compact (no empty structural nodes)', optionKey: 'compact' },
|
||||
{ short: '-d', long: '--depth', description: 'Limit tree depth (0 = root only, default: unlimited)', takesValue: true, valueHint: '<N>', optionKey: 'depth' },
|
||||
{ short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '<sel>', optionKey: 'selector' },
|
||||
{ short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' },
|
||||
{ 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: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', 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' },
|
||||
];
|
||||
|
||||
interface ParsedNode {
|
||||
@@ -233,7 +233,12 @@ export async function handleSnapshot(
|
||||
output.push(outputLine);
|
||||
}
|
||||
|
||||
// ─── Cursor-interactive scan (-C) ─────────────────────────
|
||||
// ─── Cursor-interactive scan (-C, or auto with -i) ────────
|
||||
// Auto-enable cursor scan when interactive mode is on — agents asking for
|
||||
// interactive elements should always see clickable non-ARIA items too.
|
||||
if (opts.interactive && !opts.cursorInteractive) {
|
||||
opts.cursorInteractive = true;
|
||||
}
|
||||
if (opts.cursorInteractive) {
|
||||
try {
|
||||
const cursorElements = await target.evaluate(() => {
|
||||
@@ -256,9 +261,37 @@ export async function handleSnapshot(
|
||||
const hasTabindex = el.hasAttribute('tabindex') && parseInt(el.getAttribute('tabindex')!, 10) >= 0;
|
||||
const hasRole = el.hasAttribute('role');
|
||||
|
||||
if (!hasCursorPointer && !hasOnclick && !hasTabindex) continue;
|
||||
// Skip if it has an ARIA role (likely already captured)
|
||||
if (hasRole) continue;
|
||||
// Check if element is inside a floating container (portal/popover/dropdown)
|
||||
const isInFloating = (() => {
|
||||
let parent: Element | null = el;
|
||||
while (parent && parent !== document.documentElement) {
|
||||
const pStyle = getComputedStyle(parent);
|
||||
const isFloating = (pStyle.position === 'fixed' || pStyle.position === 'absolute') &&
|
||||
parseInt(pStyle.zIndex || '0', 10) >= 10;
|
||||
const hasPortalAttr = parent.hasAttribute('data-floating-ui-portal') ||
|
||||
parent.hasAttribute('data-radix-popper-content-wrapper') ||
|
||||
parent.hasAttribute('data-radix-portal') ||
|
||||
parent.hasAttribute('data-popper-placement') ||
|
||||
parent.getAttribute('role') === 'listbox' ||
|
||||
parent.getAttribute('role') === 'menu';
|
||||
if (isFloating || hasPortalAttr) return true;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (!hasCursorPointer && !hasOnclick && !hasTabindex) {
|
||||
// For elements inside floating containers, also check for role="option"/"menuitem"
|
||||
if (isInFloating && hasRole) {
|
||||
const role = el.getAttribute('role');
|
||||
if (role !== 'option' && role !== 'menuitem' && role !== 'menuitemcheckbox' && role !== 'menuitemradio') continue;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Skip elements with ARIA roles UNLESS they're inside a floating container
|
||||
// (floating container items may be missed by the accessibility tree)
|
||||
if (hasRole && !isInFloating) continue;
|
||||
|
||||
// Build deterministic nth-child CSS path
|
||||
const parts: string[] = [];
|
||||
@@ -275,9 +308,11 @@ export async function handleSnapshot(
|
||||
|
||||
const text = (el as HTMLElement).innerText?.trim().slice(0, 80) || el.tagName.toLowerCase();
|
||||
const reasons: string[] = [];
|
||||
if (isInFloating) reasons.push('popover-child');
|
||||
if (hasCursorPointer) reasons.push('cursor:pointer');
|
||||
if (hasOnclick) reasons.push('onclick');
|
||||
if (hasTabindex) reasons.push(`tabindex=${el.getAttribute('tabindex')}`);
|
||||
if (hasRole) reasons.push(`role=${el.getAttribute('role')}`);
|
||||
|
||||
results.push({ selector, text, reason: reasons.join(', ') });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user