supapup
Version:
ā” Lightning-fast MCP browser dev tool. Navigate ā Get instant structured data. No screenshots needed! Puppeteer: šø ā CSS selectors ā JS eval. Supapup: semantic IDs ready to use. 10x faster, 90% fewer tokens.
268 lines (267 loc) ⢠10.9 kB
JavaScript
/**
* Hover state detection for Supapup
* Detects elements with hover effects and captures visual changes
*/
export class HoverDetector {
page;
constructor(page) {
this.page = page;
}
/**
* Detect all hoverable elements on the page
*/
async detectHoverableElements() {
return await this.page.evaluate(() => {
const hoverable = [];
let hoverIndex = 0;
// Helper to check if element has hover styles
function hasHoverStyles(element) {
const computed = window.getComputedStyle(element);
let confidence = 0;
let hoverType = 'none';
// Check for transitions
if (computed.transition !== 'none' && computed.transition !== 'all 0s ease 0s') {
confidence += 0.3;
hoverType = 'css';
}
// Check for cursor change
if (computed.cursor === 'pointer') {
confidence += 0.2;
}
// Check common hover selectors
const tagName = element.tagName.toLowerCase();
if (['a', 'button', 'input[type="button"]', 'input[type="submit"]'].includes(tagName)) {
confidence += 0.3;
hoverType = 'css';
}
// Check class names for hover indicators
const className = element.className;
if (className && typeof className === 'string') {
const hoverPatterns = ['hover', 'dropdown', 'tooltip', 'menu', 'btn'];
if (hoverPatterns.some(pattern => className.toLowerCase().includes(pattern))) {
confidence += 0.2;
}
}
// Check for event listeners (simplified check)
const hasMouseEvents = !!(element.onmouseover ||
element.onmouseenter ||
element.onmouseleave ||
element.onmouseout);
if (hasMouseEvents) {
confidence += 0.3;
hoverType = hoverType === 'css' ? 'both' : 'javascript';
}
// Check if element has children that might appear on hover
const children = element.querySelectorAll('*');
let hiddenChildren = 0;
children.forEach(child => {
const childStyle = window.getComputedStyle(child);
if (childStyle.display === 'none' || childStyle.visibility === 'hidden') {
hiddenChildren++;
}
});
if (hiddenChildren > 0) {
confidence += 0.3;
hoverType = 'css';
}
return {
hasHover: confidence > 0.3,
confidence: Math.min(confidence, 1),
type: hoverType
};
}
// Process all elements
const elements = document.querySelectorAll('*');
const processedSelectors = new Set();
elements.forEach(element => {
// Skip if already processed
if (element.hasAttribute('data-mcp-hover-id'))
return;
// Skip invisible elements
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0)
return;
const computed = window.getComputedStyle(element);
if (computed.display === 'none' || computed.visibility === 'hidden')
return;
// Check for hover styles
const hoverCheck = hasHoverStyles(element);
if (hoverCheck.hasHover) {
const hoverId = `hover-${hoverIndex++}`;
const selector = element.id ? `#${element.id}` :
element.className && typeof element.className === 'string' ?
`.${element.className.split(' ')[0]}` :
element.tagName.toLowerCase();
// Avoid duplicates
if (processedSelectors.has(selector))
return;
processedSelectors.add(selector);
element.setAttribute('data-mcp-hoverable', 'true');
element.setAttribute('data-mcp-hover-id', hoverId);
hoverable.push({
id: hoverId,
selector,
tagName: element.tagName.toLowerCase(),
text: element.textContent?.trim().substring(0, 50),
type: element.type || '',
hasTransition: computed.transition !== 'none',
transitionDuration: computed.transitionDuration,
hoverType: hoverCheck.type,
confidence: hoverCheck.confidence
});
}
});
return hoverable.sort((a, b) => b.confidence - a.confidence);
});
}
/**
* Capture hover sequence for a specific element
*/
async captureHoverSequence(hoverId, duration = 1000, interval = 100) {
const element = await this.page.$(`[data-mcp-hover-id="${hoverId}"]`);
if (!element)
return null;
const boundingBox = await element.boundingBox();
if (!boundingBox)
return null;
const captures = [];
const startTime = Date.now();
// Capture initial state
const initialCapture = await this.captureElementState(element, boundingBox);
captures.push({
timestamp: 0,
screenshot: initialCapture.screenshot,
styles: initialCapture.styles
});
// Trigger hover
await element.hover();
// Capture during hover
const capturePromises = [];
let currentTime = interval;
while (currentTime < duration) {
const timeToCapture = currentTime;
capturePromises.push(new Promise(async (resolve) => {
await new Promise(resolve => setTimeout(resolve, timeToCapture - (capturePromises.length * interval)));
const capture = await this.captureElementState(element, boundingBox);
captures.push({
timestamp: timeToCapture,
screenshot: capture.screenshot,
styles: capture.styles
});
resolve();
}));
currentTime += interval;
}
await Promise.all(capturePromises);
// Move mouse away to end hover
await this.page.mouse.move(0, 0);
await new Promise(resolve => setTimeout(resolve, 100));
// Capture final state
const finalCapture = await this.captureElementState(element, boundingBox);
captures.push({
timestamp: duration + 100,
screenshot: finalCapture.screenshot,
styles: finalCapture.styles
});
// Analyze changes
const changes = this.analyzeChanges(captures);
return {
hoverId,
element: {
selector: await this.page.evaluate(el => {
return el.id ? `#${el.id}` : el.className ? `.${el.className.split(' ')[0]}` : el.tagName.toLowerCase();
}, element),
bounds: boundingBox
},
captures,
changes
};
}
/**
* Capture element state including screenshot and styles
*/
async captureElementState(element, bounds) {
// Add padding to capture shadow effects
const padding = 20;
const clip = {
x: Math.max(0, bounds.x - padding),
y: Math.max(0, bounds.y - padding),
width: bounds.width + (padding * 2),
height: bounds.height + (padding * 2)
};
// Take screenshot
const screenshot = await this.page.screenshot({
clip,
encoding: 'base64'
});
// Get computed styles
const styles = await this.page.evaluate(el => {
const computed = window.getComputedStyle(el);
return {
backgroundColor: computed.backgroundColor,
color: computed.color,
transform: computed.transform,
opacity: computed.opacity,
boxShadow: computed.boxShadow,
borderColor: computed.borderColor,
filter: computed.filter,
scale: computed.scale
};
}, element);
return { screenshot, styles };
}
/**
* Analyze captures to detect changes
*/
analyzeChanges(captures) {
if (captures.length < 2) {
return {
hasVisualChange: false,
styleChanges: [],
sizeChange: false
};
}
const initial = captures[0];
const styleChanges = [];
let hasVisualChange = false;
// Compare styles across captures
for (let i = 1; i < captures.length; i++) {
const capture = captures[i];
if (capture.styles && initial.styles) {
for (const [key, value] of Object.entries(capture.styles)) {
if (value !== initial.styles[key] && !styleChanges.includes(key)) {
styleChanges.push(key);
hasVisualChange = true;
}
}
}
}
// In a real implementation, we'd compare screenshots too
// For now, we assume visual change if styles changed
return {
hasVisualChange,
styleChanges,
sizeChange: false // Would need image analysis
};
}
/**
* Generate agent page snippet for hoverable elements
*/
generateHoverableSection(elements) {
if (elements.length === 0)
return '';
let section = '\nš±ļø HOVERABLE ELEMENTS:\n';
elements.forEach(el => {
const confidence = Math.round(el.confidence * 100);
section += ` ⢠${el.tagName}`;
if (el.text)
section += `: "${el.text.substring(0, 30)}${el.text.length > 30 ? '...' : ''}"`;
section += ` ā [${el.id}]`;
if (el.hasTransition)
section += ' (animated)';
section += ` ${confidence}% confidence\n`;
});
section += '\n š” Use: page_trigger_hover({selector: "hover-id"})\n';
return section;
}
}