// Site Debug Monitor - Inject this script into your website
(function () {
"use strict";
// Configuration
const CONFIG = {
VERSION: "0.1.9.1",
PARENT_ORIGIN: "*", // Change this to your parent site origin for security
HIGHLIGHT_COLOR: "#ff6b35",
HIGHLIGHT_BG: "#ff6b3520",
DEBOUNCE_DELAY: 100,
Z_INDEX: 10000,
};
// State management
const state = {
isActive: false,
interactionMode: "select", // 'select' | 'preview'
selectedElement: null, // Single element selection (primary clicked element)
selectedGroup: [], // Group of elements with same x-id (for dynamic content)
hoverGroup: [], // Group of elements being hovered (for dynamic multi-element preview)
hoverBadge: null, // Current hover badge element
selectedBadge: null, // Current selected badge element
selectedBadges: [], // Array of badges for multi-element selection
hoverTarget: null, // Target element for hover badge (for repositioning)
repositionRAF: null, // RequestAnimationFrame ID for badge repositioning
};
// Badge Manager - handles dynamic badge positioning with viewport collision detection
class BadgeManager {
constructor() {
this.GAP = 8; // Consistent gap between element and badge
this.VIEWPORT_PADDING = 8; // Minimum distance from viewport edges
this.removalTimeouts = new Map(); // Track pending badge removals for cleanup
}
/**
* Creates a new badge element with the given label and type
* @param {string} label - The text to display in the badge
* @param {string} type - Badge type: 'hover', 'dynamic', or 'selected'
* @returns {HTMLElement} The created badge element
*/
createBadge(label, type = "hover") {
const badge = document.createElement("div");
badge.className = `debug-badge ${type}`;
badge.textContent = label;
badge.style.opacity = "0";
// Add accessibility attributes
badge.setAttribute("role", "tooltip");
badge.setAttribute("aria-live", "polite");
badge.id = `debug-badge-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
document.body.appendChild(badge);
return badge;
}
/**
* Calculates the best position for a badge relative to its target element
* Uses fallback positioning strategy: top → bottom → right → left
* @param {HTMLElement} element - The target element
* @param {HTMLElement} badge - The badge element to position
* @returns {{top: number, left: number, side: string}} Position coordinates and side
*/
calculatePosition(element, badge) {
const elementRect = element.getBoundingClientRect();
const badgeRect = badge.getBoundingClientRect();
// Define all possible positions with their fit checks
const positions = [
{
side: "top",
calc: () => ({
top: elementRect.top - badgeRect.height - this.GAP,
left: elementRect.left - 4.5, // Align with outline outer edge (3px offset + 1.5px stroke)
}),
fits: (pos) => pos.top >= this.VIEWPORT_PADDING,
},
{
side: "bottom",
calc: () => ({
top: elementRect.bottom + this.GAP,
left: elementRect.left - 4.5, // Align with outline outer edge (3px offset + 1.5px stroke)
}),
fits: (pos) =>
pos.top + badgeRect.height <=
window.innerHeight - this.VIEWPORT_PADDING,
},
{
side: "left",
calc: () => ({
top: elementRect.top,
left: elementRect.left - badgeRect.width - this.GAP,
}),
fits: (pos) => pos.left >= this.VIEWPORT_PADDING,
},
{
side: "right",
calc: () => ({
top: elementRect.top,
left: elementRect.right + this.GAP,
}),
fits: (pos) =>
pos.left + badgeRect.width <=
window.innerWidth - this.VIEWPORT_PADDING,
},
];
// Try each position in order until one fits
for (const position of positions) {
const pos = position.calc();
// Adjust horizontal position to prevent left/right edge clipping
if (position.side === "top" || position.side === "bottom") {
// Check if badge extends beyond right edge
if (
pos.left + badgeRect.width >
window.innerWidth - this.VIEWPORT_PADDING
) {
pos.left =
window.innerWidth - badgeRect.width - this.VIEWPORT_PADDING;
}
// Check if badge extends beyond left edge
if (pos.left < this.VIEWPORT_PADDING) {
pos.left = this.VIEWPORT_PADDING;
}
}
// Adjust vertical position for left/right positions
if (position.side === "left" || position.side === "right") {
// Check if badge extends beyond bottom edge
if (
pos.top + badgeRect.height >
window.innerHeight - this.VIEWPORT_PADDING
) {
pos.top =
window.innerHeight - badgeRect.height - this.VIEWPORT_PADDING;
}
// Check if badge extends beyond top edge
if (pos.top < this.VIEWPORT_PADDING) {
pos.top = this.VIEWPORT_PADDING;
}
}
if (position.fits(pos)) {
return { ...pos, side: position.side };
}
}
// Fallback: constrain to viewport (should rarely happen)
const fallback = positions[0].calc();
return {
top: Math.max(
this.VIEWPORT_PADDING,
Math.min(
fallback.top,
window.innerHeight - badgeRect.height - this.VIEWPORT_PADDING,
),
),
left: Math.max(
this.VIEWPORT_PADDING,
Math.min(
fallback.left,
window.innerWidth - badgeRect.width - this.VIEWPORT_PADDING,
),
),
side: "constrained",
};
}
/**
* Positions a badge relative to its target element
* @param {HTMLElement} element - The target element
* @param {HTMLElement} badge - The badge element to position
* @param {boolean} fadeIn - Whether to fade in the badge (default: true)
*/
positionBadge(element, badge, fadeIn = true) {
const position = this.calculatePosition(element, badge);
badge.style.top = `${position.top}px`;
badge.style.left = `${position.left}px`;
badge.dataset.side = position.side;
// Fade in the badge (only on initial creation, not during reposition)
if (fadeIn) {
requestAnimationFrame(() => {
badge.style.opacity = "1";
});
}
}
/**
* Shows a hover badge for an element
* @param {HTMLElement} element - The target element
* @param {string} label - The badge label
* @param {boolean} isDynamic - Whether the element is dynamic
* @returns {HTMLElement} The created badge element
*/
showHoverBadge(element, label, isDynamic = false) {
const type = isDynamic ? "dynamic" : "hover";
const badge = this.createBadge(label, type);
this.positionBadge(element, badge);
return badge;
}
/**
* Shows a selected badge for an element
* @param {HTMLElement} element - The target element
* @param {string} label - The badge label
* @param {boolean} isDynamic - Whether this is a dynamic element (orange badge)
* @returns {HTMLElement} The created badge element
*/
showSelectedBadge(element, label, isDynamic = false) {
const badgeType = isDynamic ? "selected-dynamic" : "selected";
const badge = this.createBadge(label, badgeType);
this.positionBadge(element, badge);
return badge;
}
/**
* Removes a badge element from the DOM
* @param {HTMLElement} badge - The badge to remove
*/
removeBadge(badge) {
if (!badge) return;
// Cancel existing removal timeout if badge is already being removed
if (this.removalTimeouts.has(badge)) {
clearTimeout(this.removalTimeouts.get(badge));
this.removalTimeouts.delete(badge);
}
if (badge.parentElement) {
badge.style.opacity = "0";
const timeoutId = setTimeout(() => {
if (badge.parentElement) {
badge.parentElement.removeChild(badge);
}
this.removalTimeouts.delete(badge);
}, 150); // Match CSS transition duration
this.removalTimeouts.set(badge, timeoutId);
}
}
/**
* Clean up all pending badge removals (called on deactivate)
*/
cleanup() {
// Clear all pending removal timeouts
this.removalTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
this.removalTimeouts.clear();
// Remove all badges immediately
document.querySelectorAll(".debug-badge").forEach((badge) => {
if (badge.parentElement) {
badge.parentElement.removeChild(badge);
}
});
}
/**
* Updates the position of a badge (for scroll/resize events)
* @param {HTMLElement} element - The target element
* @param {HTMLElement} badge - The badge to reposition
*/
updateBadgePosition(element, badge) {
if (badge && element) {
this.positionBadge(element, badge, false); // Don't fade in during reposition
}
}
}
// Create global badge manager instance
const badgeManager = new BadgeManager();
// Utility functions
function rgbToHex(rgb) {
// Handle rgb(r, g, b) and rgba(r, g, b, a) formats
const match = rgb.match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const values = match[1].split(",").map((v) => parseFloat(v.trim()));
if (values.length < 3) return null;
const r = Math.round(values[0]);
const g = Math.round(values[1]);
const b = Math.round(values[2]);
return (
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
.toUpperCase()
);
}
function extractOpacity(color) {
// Extract alpha from rgba() or return 1 for rgb()
const match = color.match(/rgba?\(([^)]+)\)/);
if (!match) return 1;
const values = match[1].split(",").map((v) => parseFloat(v.trim()));
return values.length === 4 ? values[3] : 1;
}
function parseColor(colorValue) {
if (
!colorValue ||
colorValue === "transparent" ||
colorValue === "rgba(0, 0, 0, 0)"
) {
return { hex: null, opacity: 0, hasColor: false };
}
const hex = rgbToHex(colorValue);
const opacity = extractOpacity(colorValue);
return {
hex: hex,
opacity: Math.round(opacity * 100), // Convert to percentage
hasColor: true,
};
}
function sendMessageToParent(data) {
if (window.parent && window.parent !== window) {
try {
window.parent.postMessage(
{
type: "SITE_DEBUG",
source: window.location.href,
timestamp: Date.now(),
...data,
},
CONFIG.PARENT_ORIGIN,
);
} catch (error) {
console.error("Failed to send message to parent:", error);
}
}
}
function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
function getDirectTextContent(element) {
// Extract only direct text nodes, not from child elements
let text = "";
for (let node of element.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
}
return text.trim();
}
function getOutermostMetadataWrapper(element) {
let current = element;
let wrappers = [];
// Only collect first TWO wrappers to check immediate parent relationship
// This prevents walking all the way up to root App component
while (current && wrappers.length < 2) {
if (current.hasAttribute && current.hasAttribute("x-file-name")) {
wrappers.push(current);
}
current = current.parentElement;
}
if (wrappers.length === 0) {
return null;
}
if (wrappers.length === 1) {
return wrappers[0];
}
// PRIORITY 1: Prefer the innermost wrapper when metadata is marked as directly applied
if (wrappers[0].hasAttribute("x-excluded")) {
return wrappers[0];
}
// PRIORITY 2: If the first wrapper is a debug wrapper, check for nested debug wrappers
if (wrappers[0].hasAttribute("data-debug-wrapper")) {
// Check if the second wrapper is also a debug wrapper
if (wrappers[1].hasAttribute("data-debug-wrapper")) {
// Both are debug wrappers - check file boundary
const innerFileName = wrappers[0].getAttribute("x-file-name");
const outerFileName = wrappers[1].getAttribute("x-file-name");
if (innerFileName === outerFileName) {
// Same file - use inner wrapper (actual component with content)
return wrappers[0];
}
// Different files - use outer wrapper (usage site)
return wrappers[1];
}
// Only one debug wrapper, use it
return wrappers[0];
}
// PRIORITY 3: Check if there's a file boundary between clicked element and immediate parent
const innerFileName = wrappers[0].getAttribute("x-file-name");
const outerFileName = wrappers[1].getAttribute("x-file-name");
if (innerFileName !== outerFileName) {
// File boundary crossed - return the outer wrapper (usage site)
return wrappers[1];
}
// Same file - return clicked element
return wrappers[0];
}
function unwrapDebugWrapper(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return element;
let current = element;
let safety = 0;
while (
current &&
current.hasAttribute &&
current.hasAttribute("data-debug-wrapper") &&
safety < 10
) {
const inner = current.querySelector("[x-id]");
if (!inner || inner === current) {
break;
}
current = inner;
safety += 1;
}
return current;
}
// Helper function to check if element is a portal wrapper
function isPortalWrapper(element) {
return (
element.hasAttribute("x-portal") &&
element.getAttribute("x-portal") === "true"
);
}
// Get the actual target element, skipping portal wrappers if needed
function getTargetElement(element) {
// If this is a portal wrapper, prefer the child component
if (isPortalWrapper(element) && element.children.length === 1) {
return element.children[0];
}
return element;
}
function getElementInfo(element) {
const rect = element.getBoundingClientRect();
const computedStyle = window.getComputedStyle(element);
// Extract color information
const textColor = parseColor(computedStyle.color);
const backgroundColor = parseColor(computedStyle.backgroundColor);
const borderColor = parseColor(computedStyle.borderColor);
// Get only direct text content (not from child elements)
const directText = getDirectTextContent(element);
const hasDirectTextContent = directText.length > 0;
const childElementCount = element.children ? element.children.length : 0;
return {
tagName: element.tagName.toLowerCase(),
id: element.id || null,
className: element.className || null,
textContent: directText || null,
hasDirectTextContent: hasDirectTextContent,
hasChildElements: childElementCount > 0,
childElementCount: childElementCount,
attributes: Array.from(element.attributes).reduce((acc, attr) => {
acc[attr.name] = attr.value;
return acc;
}, {}),
rect: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
},
computedStyles: {
color: textColor,
backgroundColor: backgroundColor,
borderColor: borderColor,
fontSize: computedStyle.fontSize,
fontWeight: computedStyle.fontWeight,
fontFamily: computedStyle.fontFamily,
textAlign: computedStyle.textAlign,
lineHeight: computedStyle.lineHeight,
letterSpacing: computedStyle.letterSpacing,
textDecoration: computedStyle.textDecoration,
fontStyle: computedStyle.fontStyle,
display: computedStyle.display,
position: computedStyle.position,
marginTop: computedStyle.marginTop,
marginRight: computedStyle.marginRight,
marginBottom: computedStyle.marginBottom,
marginLeft: computedStyle.marginLeft,
paddingTop: computedStyle.paddingTop,
paddingRight: computedStyle.paddingRight,
paddingBottom: computedStyle.paddingBottom,
paddingLeft: computedStyle.paddingLeft,
},
};
}
// Visual highlighting system
function createStyles() {
const style = document.createElement("style");
style.id = "debug-monitor-styles";
style.textContent = `
/* Hover state - dotted lines (editable elements) */
[data-debug-hover]:not([data-debug-selected]):not([data-debug-dynamic]) {
outline: 2px dotted #5288CC !important;
outline-offset: 2px !important;
}
/* Hover state for dynamic elements (not editable) - orange/warning color */
[data-debug-hover][data-debug-dynamic]:not([data-debug-selected]) {
outline: 2px dotted #FF8C42 !important;
outline-offset: 2px !important;
}
/* Selected state - solid lines */
[data-debug-selected]:not([data-debug-dynamic]) {
outline: 1.5px solid #2764EB !important;
outline-offset: 3px !important;
box-shadow: 0 0 0 1px rgba(39, 100, 235, 0.3), inset 0 0 0 999px rgba(39, 100, 235, 0.05) !important;
}
/* Selected state for dynamic elements - orange solid */
[data-debug-selected][data-debug-dynamic] {
outline: 1.5px solid #FF8C42 !important;
outline-offset: 3px !important;
box-shadow: 0 0 0 1px rgba(255, 140, 66, 0.3), inset 0 0 0 999px rgba(255, 140, 66, 0.05) !important;
}
/* Badge element styles (created dynamically in JS) */
.debug-badge {
position: fixed;
z-index: ${CONFIG.Z_INDEX};
pointer-events: none;
padding: 4px 6px 4px 22px;
border-radius: 6px;
font-size: 11px;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-weight: bold;
white-space: nowrap;
background-repeat: no-repeat;
background-position: 4px center;
background-size: 14px 14px;
transition: top 0.15s ease-out, left 0.15s ease-out, opacity 0.15s ease-out;
}
/* Badge variants */
.debug-badge.hover {
background-color: #DBEAFE;
border: 1px solid #B5CBF6;
color: #1E4ED8;
background-image: url('data:image/svg+xml;utf8,');
}
.debug-badge.dynamic {
background-color: #FFF5E6;
border: 1px solid #FFCC99;
color: #CC5500;
background-image: url('data:image/svg+xml;utf8,');
}
.debug-badge.selected {
background-color: #1E4ED8;
border: 1px solid #1E4ED8;
color: white;
background-image: url('data:image/svg+xml;utf8,');
}
.debug-badge.selected-dynamic {
background-color: #FF8C42;
border: 1px solid #FF8C42;
color: white;
background-image: url('data:image/svg+xml;utf8,');
}
/* Ensure debug badges are always visible */
.debug-badge {
pointer-events: none !important;
display: block !important;
}
`;
document.head.appendChild(style);
}
const INTERACTION_BLOCK_EVENTS = [
"pointerdown",
"pointerup",
"touchstart",
"touchend",
"mousedown",
"mouseup",
"contextmenu",
];
// Prevent interactive behavior when we're in select mode
function blockInteractiveEvent(event) {
if (!state.isActive) return;
if (state.interactionMode !== "select") return;
// Allow modifier-assisted interactions (e.g., holding meta for native browser behavior)
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
}
// Event handlers - simplified for click-only selection
function handleClick(event) {
if (!state.isActive) return;
// PREVIEW MODE: Allow normal interaction, don't intercept
if (state.interactionMode === "preview") {
return;
}
// SELECT MODE: Intercept for element selection
event.preventDefault();
event.stopPropagation();
const element = event.target;
// Exclude SVG elements from selection
if (element.tagName && element.tagName.toLowerCase() === "svg") {
return;
}
// Exclude specific non-editable elements by ID
if (element.id === "emergent-badge") {
console.log("[DEBUG] Excluded element by ID:", element.id);
return;
}
// Exclude Toast and Sonner components from selection
const componentName = element.getAttribute("x-component");
if (
componentName &&
(componentName.startsWith("Toast") ||
componentName === "Toaster" ||
componentName === "Sonner")
) {
console.log("[DEBUG] Excluded Toast/Sonner component:", componentName);
return;
}
const elementInfo = getElementInfo(element);
// Ensure element has x-id for tracking (assign temp ID if missing)
// This is needed for pendingChanges queue and DOM targeting
if (!elementInfo.attributes["x-id"]) {
const generatedId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
element.setAttribute("x-id", generatedId);
elementInfo.attributes["x-id"] = generatedId;
console.log(`[DEBUG] Assigned temp x-id to element: ${generatedId}`);
}
// Find outermost metadata wrapper (usage site, not definition site)
const outermostWrapper = getOutermostMetadataWrapper(element);
// Check if the wrapper is a Toast/Sonner component
if (outermostWrapper) {
const wrapperComponent = outermostWrapper.getAttribute("x-component");
if (
wrapperComponent &&
(wrapperComponent.startsWith("Toast") ||
wrapperComponent === "Toaster" ||
wrapperComponent === "Sonner")
) {
console.log(
"[DEBUG] Excluded element inside Toast/Sonner component:",
wrapperComponent,
);
return;
}
}
if (outermostWrapper) {
// If it's a debug wrapper, use its x-id AND metadata (for code editing location)
// The actual DOM changes will be applied to the inner element
if (outermostWrapper.hasAttribute("data-debug-wrapper")) {
// Use wrapper's x-id and metadata for code editing
// BUT keep the element's x-dynamic value (not the wrapper's)
elementInfo.attributes["x-id"] = outermostWrapper.getAttribute("x-id");
elementInfo.attributes["x-file-name"] =
outermostWrapper.getAttribute("x-file-name");
elementInfo.attributes["x-line-number"] =
outermostWrapper.getAttribute("x-line-number");
elementInfo.attributes["x-component"] =
outermostWrapper.getAttribute("x-component");
if (
elementInfo.attributes["x-dynamic"] == null &&
outermostWrapper.hasAttribute("x-dynamic")
) {
elementInfo.attributes["x-dynamic"] =
outermostWrapper.getAttribute("x-dynamic");
}
// DO NOT copy x-dynamic from wrapper - keep element's x-dynamic value
console.log(
`[DEBUG] Using debug wrapper x-id for code editing: ${elementInfo.attributes["x-id"]}`,
);
} else {
// Merge file metadata from wrapper (but NOT x-id - already set above)
// This ensures we capture WHERE the component is used for source file editing
const metadataAttrs = ["x-file-name", "x-line-number", "x-component"];
metadataAttrs.forEach((attr) => {
const value = outermostWrapper.getAttribute(attr);
if (value !== null) {
elementInfo.attributes[attr] = value;
}
});
// x-id points to actual element, metadata points to usage site
if (
elementInfo.attributes["x-dynamic"] == null &&
outermostWrapper.hasAttribute("x-dynamic")
) {
elementInfo.attributes["x-dynamic"] =
outermostWrapper.getAttribute("x-dynamic");
}
}
}
// Validate that element has required metadata attributes
// Elements without metadata are not part of React component tree and can't be edited
const hasRequiredMetadata =
elementInfo.attributes["x-file-name"] &&
elementInfo.attributes["x-line-number"] &&
elementInfo.attributes["x-component"];
if (!hasRequiredMetadata) {
console.log(
"[DEBUG] Element without required metadata - not editable:",
element.tagName,
);
return;
}
// Check if element is dynamic (from array iteration)
// Only check the element itself, not ancestors
const isDynamic = elementInfo.attributes["x-dynamic"] === "true";
if (isDynamic) {
console.log(
"[DEBUG] Dynamic element clicked - style editing only:",
elementInfo.attributes["x-component"],
);
}
// Clear previous selection
if (state.selectedElement && state.selectedElement !== element) {
// Clear previous single element selection
state.selectedElement.removeAttribute("data-debug-selected");
if (state.selectedBadge) {
badgeManager.removeBadge(state.selectedBadge);
state.selectedBadge = null;
}
// Clear previous group selection
state.selectedGroup.forEach((el) => {
el.removeAttribute("data-debug-selected");
});
state.selectedBadges.forEach((badge) => {
badgeManager.removeBadge(badge);
});
state.selectedGroup = [];
state.selectedBadges = [];
}
// Select new element (or deselect if clicking the same element)
if (state.selectedElement === element) {
// Deselect
state.selectedElement = null;
element.removeAttribute("data-debug-selected");
// Remove badge(s)
if (state.selectedBadge) {
badgeManager.removeBadge(state.selectedBadge);
state.selectedBadge = null;
}
state.selectedGroup.forEach((el) => {
el.removeAttribute("data-debug-selected");
el.removeAttribute("data-debug-dynamic");
});
state.selectedBadges.forEach((badge) => {
badgeManager.removeBadge(badge);
});
state.selectedGroup = [];
state.selectedBadges = [];
sendMessageToParent({
action: "ELEMENT_DESELECTED",
});
} else {
// Select new element
state.selectedElement = element;
// For dynamic elements, find ALL elements with the same x-id
if (isDynamic && elementInfo.attributes["x-id"]) {
const elementId = elementInfo.attributes["x-id"];
const allElements = document.querySelectorAll(`[x-id="${elementId}"]`);
// Unwrap any debug wrappers to get the actual visible elements
state.selectedGroup = Array.from(allElements)
.map((el) => unwrapDebugWrapper(el))
.filter(Boolean);
console.log(
`[DEBUG] Selected ${state.selectedGroup.length} dynamic elements with x-id="${elementId}"`,
);
// Mark all elements as selected with dynamic flag
state.selectedGroup.forEach((el) => {
el.setAttribute("data-debug-selected", "true");
el.setAttribute("data-debug-dynamic", "true");
});
// Create badge for the clicked element (not always first)
const label = `${element.tagName.toLowerCase()} (Dynamic)`;
const badge = badgeManager.showSelectedBadge(element, label, true);
state.selectedBadges = [badge]; // Single badge in array
} else {
// Single element selection (non-dynamic)
element.setAttribute("data-debug-selected", "true");
const label = element.tagName.toLowerCase();
state.selectedBadge = badgeManager.showSelectedBadge(
element,
label,
false,
);
}
// Get element position for widget placement
// Use viewport-relative coordinates (no scroll offset)
// Parent will add iframe position to convert to parent viewport coordinates
const rect = element.getBoundingClientRect();
sendMessageToParent({
action: "ELEMENT_SELECTED",
element: elementInfo,
isDynamic: isDynamic,
elementCount: isDynamic ? state.selectedGroup.length : 1,
isMultiElement: isDynamic && state.selectedGroup.length > 1,
position: {
x: rect.left,
y: rect.bottom, // Position below the element
width: rect.width,
height: rect.height,
elementRect: {
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right,
},
},
});
}
}
// Activity monitoring
function monitorActivity() {
// Console monitoring
const originalConsole = {
log: console.log,
warn: console.warn,
error: console.error,
};
["log", "warn", "error"].forEach((method) => {
console[method] = function (...args) {
originalConsole[method].apply(console, args);
sendMessageToParent({
action: "CONSOLE_OUTPUT",
level: method,
message: args
.map((arg) =>
typeof arg === "object" ? JSON.stringify(arg) : String(arg),
)
.join(" "),
});
};
});
// Error monitoring
window.addEventListener("error", function (event) {
sendMessageToParent({
action: "JAVASCRIPT_ERROR",
error: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
},
});
});
// Click monitoring (when not in debug mode) - use capture phase to check debug mode first
document.addEventListener(
"click",
function (event) {
// Only log non-debug clicks if debug mode is not active
if (!state.isActive) {
sendMessageToParent({
action: "USER_CLICK",
element: getElementInfo(event.target),
});
}
},
false,
); // Use bubbling phase, not capture
// Scroll monitoring
let scrollTimeout;
window.addEventListener("scroll", function () {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
sendMessageToParent({
action: "SCROLL",
position: {
x: window.scrollX,
y: window.scrollY,
},
});
}, 100);
});
}
// Message handler for parent communication
function handleParentMessage(event) {
// For security, you should set CONFIG.PARENT_ORIGIN to your actual parent origin
if (event.origin !== CONFIG.PARENT_ORIGIN && CONFIG.PARENT_ORIGIN !== "*")
return;
const { type, action, data } = event.data;
if (type !== "DEBUG_COMMAND") return;
switch (action) {
case "ACTIVATE":
activateDebugMode();
break;
case "DEACTIVATE":
deactivateDebugMode();
break;
case "CLEAR_SELECTION":
clearSelection();
break;
case "GET_PAGE_INFO":
sendPageInfo();
break;
case "APPLY_CHANGES":
applyElementChanges(event.data.data);
break;
case "SET_INTERACTION_MODE":
setInteractionMode(data.mode);
break;
}
}
// Hover handlers
function handleMouseEnter(event) {
if (!state.isActive) return;
// PREVIEW MODE: No hover effects
if (state.interactionMode === "preview") {
return;
}
// SELECT MODE: Show hover effects
const element = event.target;
// Validate element is a proper DOM Element node
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
// Don't add hover effect to already selected element
if (element.hasAttribute("data-debug-selected")) return;
// Exclude SVG elements from hover highlighting
if (element.tagName && element.tagName.toLowerCase() === "svg") {
return;
}
// Exclude specific non-editable elements by ID
if (element.id === "emergent-badge") {
return;
}
// Exclude Toast and Sonner components from hover
const componentName = element.getAttribute("x-component");
if (
componentName &&
(componentName.startsWith("Toast") ||
componentName === "Toaster" ||
componentName === "Sonner")
) {
return;
}
// Check if element has required metadata attributes
// Elements without metadata are not part of React component tree
const hasRequiredMetadata =
element.hasAttribute("x-file-name") &&
element.hasAttribute("x-line-number") &&
element.hasAttribute("x-component");
// Also check if any parent has the metadata (for nested elements)
const outermostWrapper = getOutermostMetadataWrapper(element);
if (!hasRequiredMetadata && !outermostWrapper) {
// No metadata on element or any parent - skip hover
return;
}
// Check if the wrapper is a Toast/Sonner component
if (outermostWrapper) {
const wrapperComponent = outermostWrapper.getAttribute("x-component");
if (
wrapperComponent &&
(wrapperComponent.startsWith("Toast") ||
wrapperComponent === "Toaster" ||
wrapperComponent === "Sonner")
) {
return;
}
}
// Check if element is dynamic
let isDynamic = element.getAttribute("x-dynamic") === "true";
if (!isDynamic) {
const wrapper = getOutermostMetadataWrapper(element);
if (wrapper && wrapper.getAttribute("x-dynamic") === "true") {
isDynamic = true;
}
}
// For dynamic elements, highlight ALL instances with same x-id
if (isDynamic) {
const elementId =
element.getAttribute("x-id") ||
element.closest("[x-id]")?.getAttribute("x-id");
if (elementId) {
// Find all elements with same x-id
const allElements = document.querySelectorAll(`[x-id="${elementId}"]`);
// Unwrap any debug wrappers to get the actual visible elements
state.hoverGroup = Array.from(allElements)
.map((el) => unwrapDebugWrapper(el))
.filter(Boolean);
// Check if ANY element in the group is selected
const anySelected = state.hoverGroup.some((el) =>
el.hasAttribute("data-debug-selected"),
);
if (anySelected) {
// Don't show hover effects if group is already selected
state.hoverGroup = [];
return;
}
// Mark all elements with hover + dynamic
state.hoverGroup.forEach((el) => {
el.setAttribute("data-debug-hover", "true");
el.setAttribute("data-debug-dynamic", "true");
});
} else {
// Fallback: single element if no x-id found
element.setAttribute("data-debug-hover", "true");
element.setAttribute("data-debug-dynamic", "true");
}
} else {
// Single element hover (non-dynamic)
element.setAttribute("data-debug-hover", "true");
}
// Create and show badge
const label = isDynamic
? `${element.tagName.toLowerCase()} (Dynamic)`
: element.tagName.toLowerCase();
// Remove previous hover badge if exists
if (state.hoverBadge) {
badgeManager.removeBadge(state.hoverBadge);
}
// Store target element reference for repositioning
state.hoverTarget = element;
state.hoverBadge = badgeManager.showHoverBadge(element, label, isDynamic);
}
function handleMouseLeave(event) {
if (!state.isActive) return;
// PREVIEW MODE: No hover effects to remove
if (state.interactionMode === "preview") {
return;
}
// SELECT MODE: Remove hover effects
const element = event.target;
// Validate element is a proper DOM Element node
if (!element || element.nodeType !== Node.ELEMENT_NODE) return;
// Clear single element or all elements in hoverGroup
if (state.hoverGroup.length > 0) {
// Multi-element hover - clear all
state.hoverGroup.forEach((el) => {
el.removeAttribute("data-debug-hover");
// Only remove data-debug-dynamic if element is not selected
if (!el.hasAttribute("data-debug-selected")) {
el.removeAttribute("data-debug-dynamic");
}
});
state.hoverGroup = [];
} else {
// Single element hover - clear just this one
element.removeAttribute("data-debug-hover");
// Only remove data-debug-dynamic if element is not selected
if (!element.hasAttribute("data-debug-selected")) {
element.removeAttribute("data-debug-dynamic");
}
}
// Remove hover badge and clear target reference
if (state.hoverBadge) {
badgeManager.removeBadge(state.hoverBadge);
state.hoverBadge = null;
state.hoverTarget = null;
}
}
// Badge repositioning on scroll/resize
let repositionTimeout;
function handleBadgeReposition() {
// Immediately hide badges and disable transitions to prevent visual lag during scroll
if (state.hoverBadge) {
state.hoverBadge.style.opacity = "0";
state.hoverBadge.style.transition = "none";
}
if (state.selectedBadge) {
state.selectedBadge.style.opacity = "0";
state.selectedBadge.style.transition = "none";
}
if (state.selectedBadges.length > 0) {
state.selectedBadges.forEach((badge) => {
badge.style.opacity = "0";
badge.style.transition = "none";
});
}
clearTimeout(repositionTimeout);
repositionTimeout = setTimeout(() => {
// Cancel pending RAF to prevent stacking
if (state.repositionRAF) {
cancelAnimationFrame(state.repositionRAF);
}
state.repositionRAF = requestAnimationFrame(() => {
// Reposition hover badge using stored target reference
if (state.hoverBadge && state.hoverTarget) {
badgeManager.updateBadgePosition(state.hoverTarget, state.hoverBadge);
// Force reflow to commit position changes before re-enabling transitions
void state.hoverBadge.offsetHeight;
// Re-enable transitions and show badge
state.hoverBadge.style.transition = "";
state.hoverBadge.style.opacity = "1";
}
// Reposition selected badge (single element)
if (state.selectedBadge && state.selectedElement) {
badgeManager.updateBadgePosition(
state.selectedElement,
state.selectedBadge,
);
// Force reflow to commit position changes before re-enabling transitions
void state.selectedBadge.offsetHeight;
// Re-enable transitions and show badge
state.selectedBadge.style.transition = "";
state.selectedBadge.style.opacity = "1";
}
// Reposition badge for multi-element selection (only first element has badge)
if (state.selectedBadges.length > 0 && state.selectedGroup.length > 0) {
badgeManager.updateBadgePosition(
state.selectedGroup[0],
state.selectedBadges[0],
);
// Force reflow to commit position changes before re-enabling transitions
void state.selectedBadges[0].offsetHeight;
// Re-enable transitions and show badge
state.selectedBadges[0].style.transition = "";
state.selectedBadges[0].style.opacity = "1";
}
state.repositionRAF = null;
});
}, 50); // 50ms debounce for smooth performance
}
// Debug mode controls
function activateDebugMode() {
state.isActive = true;
// Set cursor based on current mode
document.body.style.cursor =
state.interactionMode === "select" ? "crosshair" : "default";
// Add class to body for select mode to disable hover components
if (state.interactionMode === "select") {
document.body.classList.add("debug-select-mode");
}
// Add event listeners
document.addEventListener("click", handleClick, true);
document.addEventListener("mouseover", handleMouseEnter, true);
document.addEventListener("mouseout", handleMouseLeave, true);
INTERACTION_BLOCK_EVENTS.forEach((eventName) => {
document.addEventListener(eventName, blockInteractiveEvent, true);
});
// Add scroll and resize listeners for badge repositioning
window.addEventListener("scroll", handleBadgeReposition, { passive: true });
window.addEventListener("resize", handleBadgeReposition, { passive: true });
sendMessageToParent({
action: "DEBUG_MODE_ACTIVATED",
url: window.location.href,
});
}
function deactivateDebugMode() {
state.isActive = false;
document.body.style.cursor = "";
document.body.classList.remove("debug-select-mode");
// Remove event listeners
document.removeEventListener("click", handleClick, true);
document.removeEventListener("mouseover", handleMouseEnter, true);
document.removeEventListener("mouseout", handleMouseLeave, true);
INTERACTION_BLOCK_EVENTS.forEach((eventName) => {
document.removeEventListener(eventName, blockInteractiveEvent, true);
});
window.removeEventListener("scroll", handleBadgeReposition);
window.removeEventListener("resize", handleBadgeReposition);
// Clear pending timers and RAF
clearTimeout(repositionTimeout);
repositionTimeout = null;
if (state.repositionRAF) {
cancelAnimationFrame(state.repositionRAF);
state.repositionRAF = null;
}
// Clear selection
clearSelection();
// Clean up all pending badge removals
badgeManager.cleanup();
sendMessageToParent({
action: "DEBUG_MODE_DEACTIVATED",
});
}
function clearSelection() {
if (state.selectedElement) {
state.selectedElement.removeAttribute("data-debug-selected");
state.selectedElement = null;
}
// Remove selected badge (single element)
if (state.selectedBadge) {
badgeManager.removeBadge(state.selectedBadge);
state.selectedBadge = null;
}
// Clear all elements in multi-element selection
state.selectedGroup.forEach((el) => {
el.removeAttribute("data-debug-selected");
el.removeAttribute("data-debug-dynamic");
});
state.selectedGroup = [];
// Remove all badges in multi-element selection
state.selectedBadges.forEach((badge) => {
badgeManager.removeBadge(badge);
});
state.selectedBadges = [];
// Remove hover badge
if (state.hoverBadge) {
badgeManager.removeBadge(state.hoverBadge);
state.hoverBadge = null;
}
// Clear all hover effects
const hoveredElements = document.querySelectorAll("[data-debug-hover]");
hoveredElements.forEach((el) => {
el.removeAttribute("data-debug-hover");
el.removeAttribute("data-debug-dynamic");
});
sendMessageToParent({
action: "ELEMENT_DESELECTED",
});
}
function setInteractionMode(mode) {
state.interactionMode = mode;
// Update cursor based on mode
if (state.isActive) {
document.body.style.cursor = mode === "select" ? "crosshair" : "default";
// Add/remove class for select mode
if (mode === "select") {
document.body.classList.add("debug-select-mode");
} else {
document.body.classList.remove("debug-select-mode");
}
}
sendMessageToParent({
action: "INTERACTION_MODE_CHANGED",
mode: mode,
});
}
function sendPageInfo() {
sendMessageToParent({
action: "PAGE_INFO",
info: {
title: document.title,
url: window.location.href,
domain: window.location.hostname,
elements: {
total: document.querySelectorAll("*").length,
images: document.querySelectorAll("img").length,
links: document.querySelectorAll("a").length,
forms: document.querySelectorAll("form").length,
inputs: document.querySelectorAll("input").length,
},
viewport: {
width: window.innerWidth,
height: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY,
},
},
});
}
// Apply changes to the selected element or by element ID
function applyElementChanges(changes) {
let targetElements = [];
// If elementId is provided, find element(s) by x-id attribute
if (changes.elementId) {
// Check if this is a multi-element update (dynamic content)
if (changes.isMultiElement) {
const elements = document.querySelectorAll(
`[x-id="${changes.elementId}"]`,
);
targetElements = Array.from(elements)
.map((el) => unwrapDebugWrapper(el))
.filter(Boolean);
console.log(
`[APPLY_CHANGES] Targeting ${targetElements.length} elements by ID: ${changes.elementId}`,
);
} else {
const element = document.querySelector(`[x-id="${changes.elementId}"]`);
if (!element) {
console.warn(`No element found with x-id="${changes.elementId}"`);
sendMessageToParent({
action: "CHANGES_ERROR",
error: `Element not found: x-id="${changes.elementId}"`,
elementId: changes.elementId,
});
return;
}
// If this is a debug wrapper, apply changes to the inner element instead
if (element.hasAttribute("data-debug-wrapper")) {
const innerElement = unwrapDebugWrapper(element);
if (innerElement) {
targetElements = [innerElement];
console.log(
`[APPLY_CHANGES] Found debug wrapper(s), applying to inner element: ${innerElement.getAttribute("x-id") || innerElement.tagName}`,
);
} else {
console.warn(`Debug wrapper has no inner element with x-id`);
targetElements = [element];
}
} else {
targetElements = [element];
console.log(
`[APPLY_CHANGES] Targeting element by ID: ${changes.elementId}`,
);
}
}
} else {
// Fallback to selected element for backwards compatibility
if (!state.selectedElement) {
console.warn("No element selected and no elementId provided");
sendMessageToParent({
action: "CHANGES_ERROR",
error: "No element selected and no elementId provided",
});
return;
}
targetElements = [state.selectedElement];
console.log("[APPLY_CHANGES] Targeting currently selected element");
}
if (targetElements.length === 0) {
console.warn("No elements found to apply changes to");
sendMessageToParent({
action: "CHANGES_ERROR",
error: "No elements found",
elementId: changes.elementId,
});
return;
}
let applied = [];
try {
// Apply changes to all target elements
targetElements.forEach((element, index) => {
// Apply text content changes ONLY if element has direct text content
// Skip for multi-element updates (dynamic content should not have content changes)
if (changes.textContent !== undefined && !changes.isMultiElement) {
const hasDirectText =
element.getAttribute("x-direct-text") === "true";
const hasChildren = element.children && element.children.length > 0;
if (hasDirectText || !hasChildren) {
// Safe to apply textContent: element has direct text OR no children
element.textContent = changes.textContent;
if (index === 0) applied.push("textContent");
} else {
// Mixed content: update an existing text node (or create one) without touching child elements
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE,
);
const firstTextNode =
textNodes.find(
(node) =>
node.textContent && node.textContent.trim().length > 0,
) || textNodes[0];
if (firstTextNode) {
// Preserve original leading/trailing whitespace if the update omits it
const originalText = firstTextNode.textContent || "";
const originalLeading = (originalText.match(/^\s+/) || [""])[0];
const originalTrailing = (originalText.match(/\s+$/) || [""])[0];
const updateLeading = (changes.textContent.match(/^\s+/) || [
"",
])[0];
const updateTrailing = (changes.textContent.match(/\s+$/) || [
"",
])[0];
const coreText = changes.textContent.trim();
firstTextNode.textContent = `${updateLeading || originalLeading}${coreText}${updateTrailing || originalTrailing}`;
} else {
const textNode = document.createTextNode(changes.textContent);
element.insertBefore(textNode, element.firstChild || null);
}
if (index === 0) {
applied.push("textContent");
console.log(
`[APPLY_CHANGES] textContent updated for ${changes.elementId} without modifying child elements`,
);
}
}
}
// Apply class changes
if (changes.className !== undefined) {
const beforeClassName = element.className;
element.className = changes.className;
if (index === 0) applied.push("className");
if (index === 0 || targetElements.length <= 3) {
console.log(`[APPLY_CHANGES] className updated for element ${index + 1}/${targetElements.length}:
Before: "${beforeClassName}"
After: "${changes.className}"
Changed: ${beforeClassName !== changes.className ? "YES" : "NO (same value)"}`);
}
}
// Apply ID changes (only for single element - multiple elements can't share same ID)
if (changes.id !== undefined && !changes.isMultiElement) {
if (changes.id) {
element.id = changes.id;
} else {
element.removeAttribute("id");
}
if (index === 0) applied.push("id");
}
// Apply custom attributes
if (changes.attributes && typeof changes.attributes === "object") {
Object.entries(changes.attributes).forEach(([key, value]) => {
if (value) {
element.setAttribute(key, value);
} else {
element.removeAttribute(key);
}
});
if (index === 0) applied.push("attributes");
}
});
// Send success message
sendMessageToParent({
action: "CHANGES_APPLIED",
applied: applied,
elementId: changes.elementId,
elementCount: targetElements.length,
element: getElementInfo(targetElements[0]),
});
} catch (error) {
console.error("Error applying changes:", error);
sendMessageToParent({
action: "CHANGES_ERROR",
error: error.message,
elementId: changes.elementId,
});
}
}
// Initialize
function init() {
// Only run if we're in an iframe
if (window.parent === window) {
console.log("Site Debug Monitor: Not in iframe, monitoring disabled");
return;
}
createStyles();
// Set up communication
window.addEventListener("message", handleParentMessage);
// Start monitoring
monitorActivity();
// Send initial message
sendMessageToParent({
action: "MONITOR_READY",
url: window.location.href,
version: CONFIG.VERSION,
});
console.log(`Site Debug Monitor: Ready (v${CONFIG.VERSION})`);
}
// Auto-initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
// Expose controls globally for manual testing
window.siteDebugMonitor = {
activate: activateDebugMode,
deactivate: deactivateDebugMode,
clearSelection: clearSelection,
getState: () => ({ ...state }),
sendPageInfo: sendPageInfo,
};
})();