From e41acf117637a6e6ffd6e620137f6811cb41f3cb Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Mon, 2 Mar 2026 02:00:33 +0000 Subject: [PATCH 1/2] Add deep select: Cmd+hover to pierce through overlays and wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold Cmd (Mac) / Ctrl (Win) while hovering to scan through invisible overlays, empty container wrappers, and opacity:0 animation ghosts to select the actual content underneath. Two-pass pierce algorithm: 1. Find elements with direct text content (skips empty wrappers) 2. Smallest visible element fallback (catches visual-only elements) Also improves element identification: direct text content is now checked before class names, so "$54" shows instead of "styles.price". Visual indicators: dashed highlight border and "⇣ deep select" label in the hover tooltip when pierce mode is active. Adds animated demo to the features page with keyboard shortcut entry. --- package/example/src/app/SideNav.tsx | 1 + .../src/app/components/DeepSelectDemo.css | 221 ++++++++++ .../src/app/components/DeepSelectDemo.tsx | 407 ++++++++++++++++++ package/example/src/app/features/page.tsx | 18 +- .../src/components/page-toolbar-css/index.tsx | 136 +++++- .../page-toolbar-css/styles.module.scss | 7 + package/src/utils/element-identification.ts | 21 + package/src/utils/react-detection.ts | 8 + 8 files changed, 803 insertions(+), 16 deletions(-) create mode 100644 package/example/src/app/components/DeepSelectDemo.css create mode 100644 package/example/src/app/components/DeepSelectDemo.tsx diff --git a/package/example/src/app/SideNav.tsx b/package/example/src/app/SideNav.tsx index db3d87ab..c5398165 100644 --- a/package/example/src/app/SideNav.tsx +++ b/package/example/src/app/SideNav.tsx @@ -391,6 +391,7 @@ export function SideNav() { { id: 'smart-identification', text: 'Smart Identification' }, { id: 'computed-styles', text: 'Computed Styles' }, { id: 'react-detection', text: 'React Detection' }, + { id: 'deep-select', text: 'Deep Select' }, { id: 'keyboard-shortcuts', text: 'Keyboard Shortcuts' }, { id: 'agent-sync', text: 'Agent Sync' }, { id: 'settings', text: 'Settings' }, diff --git a/package/example/src/app/components/DeepSelectDemo.css b/package/example/src/app/components/DeepSelectDemo.css new file mode 100644 index 00000000..6f8719ca --- /dev/null +++ b/package/example/src/app/components/DeepSelectDemo.css @@ -0,0 +1,221 @@ +/* ───────────────────────────────────────────────────────── + * Deep Select Demo + * Reuses shared demo-window / demo-browser-bar / demo-content + * from FeaturesDemo.css + * ───────────────────────────────────────────────────────── */ + +/* Page layout — sidebar + main */ +.dsd-page-layout { + display: flex; + gap: 12px; + height: 100%; +} + +.dsd-sidebar { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 2px; +} + +.dsd-sidebar-item { + width: 28px; + height: 28px; + border-radius: 6px; + background: rgba(0,0,0,0.06); +} + +.dsd-sidebar-item.active { + background: rgba(0,0,0,0.12); +} + +.dsd-main { + flex: 1; + min-width: 0; +} + +.dsd-faux-title { + width: 90px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + margin-bottom: 10px; +} + +/* Dashboard card */ +.dsd-card { + position: relative; + background: white; + border-radius: 10px; + padding: 12px 14px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.dsd-card-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.dsd-card-icon { + width: 24px; + height: 24px; + background: #3b82f6; + border-radius: 6px; + opacity: 0.15; +} + +.dsd-card-label { + font-size: 11px; + font-weight: 500; + color: rgba(0,0,0,0.4); + font-family: system-ui, sans-serif; +} + +.dsd-card-value { + font-size: 20px; + font-weight: 700; + color: rgba(0,0,0,0.8); + font-family: system-ui, sans-serif; + margin-bottom: 8px; + letter-spacing: -0.5px; +} + +/* Mini bar chart — stronger opacity so bars read as data */ +.dsd-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 28px; + margin-bottom: 10px; +} + +.dsd-chart-bar { + flex: 1; + background: #3b82f6; + border-radius: 3px 3px 0 0; + opacity: 0.22; +} + +.dsd-chart-bar:nth-child(n+5) { + opacity: 0.35; +} + +/* Export button — the target element */ +.dsd-export-btn { + display: inline-block; + padding: 6px 14px; + background: rgba(0,0,0,0.8); + color: white; + font-size: 10px; + font-weight: 600; + font-family: system-ui, sans-serif; + border-radius: 6px; +} + +/* Secondary metric cards below the main card */ +.dsd-mini-cards { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.dsd-mini-card { + flex: 1; + background: white; + border-radius: 8px; + padding: 8px 10px; + box-shadow: 0 1px 3px rgba(0,0,0,0.06); +} + +.dsd-mini-label { + font-size: 9px; + font-weight: 500; + color: rgba(0,0,0,0.35); + font-family: system-ui, sans-serif; + margin-bottom: 2px; +} + +.dsd-mini-value { + font-size: 14px; + font-weight: 700; + color: rgba(0,0,0,0.7); + font-family: system-ui, sans-serif; + letter-spacing: -0.3px; +} + +/* Invisible overlay — the problem this feature solves */ +.dsd-overlay { + position: absolute; + inset: 0; + border-radius: 10px; + z-index: 2; + background: rgba(239, 68, 68, 0); + transition: background 0.3s ease; +} + +.dsd-overlay.flash { + background: rgba(239, 68, 68, 0.05); +} + +/* Hover highlight */ +.ds-highlight { + position: absolute; + border-radius: 6px; + pointer-events: none; + z-index: 30; + opacity: 0; + transition: opacity 0.15s ease, border-color 0.15s ease; +} + +.ds-highlight.visible { + opacity: 1; +} + +.ds-highlight.normal { + border: 2px solid #3b82f6; +} + +.ds-highlight.pierce { + border: 2px dashed #3b82f6; +} + +/* Hover tooltip — shows element name */ +.ds-tooltip { + position: absolute; + padding: 3px 8px; + border-radius: 6px; + font-size: 10px; + font-weight: 500; + font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace; + white-space: nowrap; + pointer-events: none; + z-index: 35; + opacity: 0; + transform: translateY(2px); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.ds-tooltip.visible { + opacity: 1; + transform: translateY(0); +} + +.ds-tooltip.wrong { + background: rgba(0,0,0,0.75); + color: rgba(255,255,255,0.5); +} + +.ds-tooltip.correct { + background: rgba(0,0,0,0.85); + color: rgba(255,255,255,0.95); +} + +/* Pierce label on tooltip — readable, not decorative */ +.ds-pierce-label { + font-size: 9px; + color: rgba(255,255,255,0.5); + letter-spacing: 0.03em; + margin-bottom: 1px; +} diff --git a/package/example/src/app/components/DeepSelectDemo.tsx b/package/example/src/app/components/DeepSelectDemo.tsx new file mode 100644 index 00000000..a876b97e --- /dev/null +++ b/package/example/src/app/components/DeepSelectDemo.tsx @@ -0,0 +1,407 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import "./FeaturesDemo.css"; +import "./DeepSelectDemo.css"; + +function delay(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + +/* ───────────────────────────────────────────────────────── + * ANIMATION STORYBOARD + * + * 0ms reset — cursor top-right, caption: idle + * 600ms cursor moves toward "Export" button + * 1000ms NORMAL: solid highlight on entire card + overlay flash + * → tooltip: div.AnimatePresence (wrong/dimmed) + * → caption: "Normal hover selects the invisible wrapper." + * 2600ms highlight + tooltip fade out + * 3000ms caption: "Hold ⌘ to pierce through overlay layers." + * 5000ms PIERCE: dashed highlight on just the button + * → tooltip: button "Export" (correct/bright) + * → caption: "Deep select finds the actual element underneath." + * 6400ms popup appears, types "Add loading state" + * 8200ms popup closes, marker placed + * 10200ms marker fades, loop + * ───────────────────────────────────────────────────────── */ + +const LOOP_INTERVAL = 11800; +const CHART_HEIGHTS = [45, 60, 35, 75, 55, 90, 70, 85]; + +type CaptionKey = "idle" | "cmd" | "correct"; + +const CAPTIONS: Record = { + idle: "Normal hover selects the invisible animation wrapper.", + cmd: "Hold \u2318 to pierce through overlay layers.", + correct: "Deep select finds the actual element underneath.", +}; + +export function DeepSelectDemo() { + const [cursorPos, setCursorPos] = useState({ x: 280, y: 40 }); + const [activeCaption, setActiveCaption] = useState("idle"); + + const [highlight, setHighlight] = useState<{ + visible: boolean; + mode: "normal" | "pierce"; + rect: { x: number; y: number; w: number; h: number }; + }>({ visible: false, mode: "normal", rect: { x: 0, y: 0, w: 0, h: 0 } }); + + const [tooltip, setTooltip] = useState<{ + visible: boolean; + text: string; + type: "wrong" | "correct"; + x: number; + y: number; + }>({ visible: false, text: "", type: "wrong", x: 0, y: 0 }); + + const [showPopup, setShowPopup] = useState(false); + const [typedText, setTypedText] = useState(""); + const [showMarker, setShowMarker] = useState(false); + const [overlayFlash, setOverlayFlash] = useState(false); + + const cardRef = useRef(null); + const btnRef = useRef(null); + const contentRef = useRef(null); + + const cardPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + const btnPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + + const measure = () => { + if (!cardRef.current || !btnRef.current || !contentRef.current) return; + const cRect = contentRef.current.getBoundingClientRect(); + const cardRect = cardRef.current.getBoundingClientRect(); + const bRect = btnRef.current.getBoundingClientRect(); + cardPosRef.current = { + x: cardRect.left - cRect.left, + y: cardRect.top - cRect.top, + w: cardRect.width, + h: cardRect.height, + }; + btnPosRef.current = { + x: bRect.left - cRect.left, + y: bRect.top - cRect.top, + w: bRect.width, + h: bRect.height, + }; + }; + + useEffect(() => { + const timer = setTimeout(measure, 100); + window.addEventListener("resize", measure); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", measure); + }; + }, []); + + useEffect(() => { + let cancelled = false; + const feedbackText = "Add loading state"; + + const run = async () => { + // Reset + setCursorPos({ x: 280, y: 40 }); + setHighlight({ visible: false, mode: "normal", rect: { x: 0, y: 0, w: 0, h: 0 } }); + setTooltip({ visible: false, text: "", type: "wrong", x: 0, y: 0 }); + setShowPopup(false); + setTypedText(""); + setShowMarker(false); + setOverlayFlash(false); + setActiveCaption("idle"); + + await delay(600); + if (cancelled) return; + + // Re-measure before using positions (layout may have shifted) + measure(); + const card = cardPosRef.current; + const btn = btnPosRef.current; + setCursorPos({ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2 }); + await delay(400); + if (cancelled) return; + + // Normal hover — highlights the whole card (the overlay intercepts) + setOverlayFlash(true); + setHighlight({ + visible: true, + mode: "normal", + rect: { x: card.x - 3, y: card.y - 3, w: card.w + 6, h: card.h + 6 }, + }); + setTooltip({ + visible: true, + text: "div.AnimatePresence", + type: "wrong", + x: card.x + card.w / 2, + y: card.y - 10, + }); + await delay(1600); + if (cancelled) return; + + // Fade highlight + tooltip + overlay flash + setHighlight((h) => ({ ...h, visible: false })); + setTooltip((t) => ({ ...t, visible: false })); + setOverlayFlash(false); + await delay(400); + if (cancelled) return; + + // ⌘ beat — caption explains the feature + setActiveCaption("cmd"); + await delay(2000); + if (cancelled) return; + + // Pierce hover — highlights just the button + setActiveCaption("correct"); + setHighlight({ + visible: true, + mode: "pierce", + rect: { x: btn.x - 3, y: btn.y - 3, w: btn.w + 6, h: btn.h + 6 }, + }); + setTooltip({ + visible: true, + text: 'button "Export"', + type: "correct", + x: btn.x + btn.w / 2, + y: btn.y - 10, + }); + await delay(1400); + if (cancelled) return; + + // Click — show popup + setShowPopup(true); + await delay(300); + if (cancelled) return; + + // Type feedback + for (let i = 0; i <= feedbackText.length; i++) { + if (cancelled) return; + setTypedText(feedbackText.slice(0, i)); + await delay(30); + } + await delay(400); + if (cancelled) return; + + // Close popup, show marker + setShowPopup(false); + setHighlight((h) => ({ ...h, visible: false })); + setTooltip((t) => ({ ...t, visible: false })); + await delay(200); + if (cancelled) return; + setShowMarker(true); + + await delay(2200); + if (cancelled) return; + + // Clean up for next loop + setShowMarker(false); + await delay(300); + }; + + run(); + let interval = setInterval(run, LOOP_INTERVAL); + + const handleVisibility = () => { + if (document.visibilityState === "visible") { + cancelled = true; + clearInterval(interval); + setTimeout(() => { + cancelled = false; + run(); + interval = setInterval(run, LOOP_INTERVAL); + }, 100); + } + }; + document.addEventListener("visibilitychange", handleVisibility); + + return () => { + cancelled = true; + clearInterval(interval); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, []); + + return ( +
+
+
+
+
+
+
localhost:3000/dashboard
+
+ +
+
+ {/* Sidebar nav */} +
+
+
+
+
+
+ + {/* Main content area */} +
+
+ +
+ {/* Invisible overlay — the problem */} +
+
+
+
Monthly Revenue
+
+
$12.4k
+
+ {CHART_HEIGHTS.map((h, i) => ( +
+ ))} +
+
Export
+
+ + {/* Secondary metric cards */} +
+
+
Users
+
1,847
+
+
+
Conversion
+
3.2%
+
+
+
+
+ + {/* Highlight */} +
+ + {/* Tooltip */} +
+ {tooltip.type === "correct" &&
{"\u21E3"} deep select
} + {tooltip.text} +
+ + {/* Popup */} +
+
button "Export"
+
+ {typedText}| +
+
+
Cancel
+
Add
+
+
+ + {/* Marker */} +
+ 1 +
+ + {/* Cursor — offset by half SVG size so crosshair center lands on target */} +
+ + + + +
+ + {/* Toolbar */} +
+
+ + + + + +
+ +
+
+
+
+ + {/* Caption — updates with animation state, matching SmartIdentificationDemo pattern */} +

+ {CAPTIONS[activeCaption]} +

+
+ ); +} + +/* ───────────────────────────────────────────────────────── + * Shared toolbar icon — same as FeaturesDemo + * ───────────────────────────────────────────────────────── */ + +function ToolbarIcon({ icon, disabled }: { icon: string; disabled?: boolean }) { + const disabledStyle = disabled ? { opacity: 0.35 } : undefined; + + const icons: Record = { + pause: ( + + + + + ), + eye: ( + + + + + ), + copy: ( + + + + + ), + trash: ( + + + + + + + + ), + settings: ( + + + + + ), + close: ( + + + + + ), + }; + + return ( +
+
+ {icons[icon]} +
+
+ ); +} diff --git a/package/example/src/app/features/page.tsx b/package/example/src/app/features/page.tsx index 0996afff..bcbc6c0d 100644 --- a/package/example/src/app/features/page.tsx +++ b/package/example/src/app/features/page.tsx @@ -2,6 +2,7 @@ import { Footer } from "../Footer"; import { FeaturesDemo, SettingsDemo, SmartIdentificationDemo, MarkerKeyDemo, ComputedStylesDemo, ReactDetectionDemo, AgentChatDemo } from "../components/FeaturesDemo"; +import { DeepSelectDemo } from "../components/DeepSelectDemo"; export default function FeaturesPage() { return ( @@ -82,6 +83,15 @@ export default function FeaturesPage() { +
+

Deep select

+

+ Hold while hovering to pierce through invisible wrappers and select the actual element underneath. + Useful when animation libraries, video frameworks, or overlay patterns render empty divs on top of your content. +

+ +
+

Keyboard shortcuts

@@ -107,8 +117,12 @@ export default function FeaturesPage() { - - + + + + + +
Copy feedback
XClear all annotationsXClear all annotations
/ Ctrl + hoverDeep select (pierce overlays)
diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index a2b26b15..407c8f9d 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -128,6 +128,7 @@ type HoverInfo = { elementPath: string; rect: DOMRect | null; reactComponents?: string | null; + isPiercing?: boolean; }; type OutputDetailLevel = "compact" | "standard" | "detailed" | "forensic"; @@ -206,22 +207,113 @@ const COLOR_OPTIONS = [ // ============================================================================= /** - * Recursively pierces shadow DOMs to find the deepest element at a point. - * document.elementFromPoint() stops at shadow hosts, so we need to - * recursively check inside open shadow roots to find the actual target. + * Pierce through shadow DOMs to find the actual element. + */ +function pierceShadowDOM( + element: HTMLElement, + x: number, + y: number, +): HTMLElement { + let el = element; + while (el?.shadowRoot) { + const deeper = el.shadowRoot.elementFromPoint(x, y) as HTMLElement | null; + if (!deeper || deeper === el) break; + el = deeper; + } + return el; +} + +/** + * Finds the deepest element at a point, piercing shadow DOMs. */ function deepElementFromPoint(x: number, y: number): HTMLElement | null { - let element = document.elementFromPoint(x, y) as HTMLElement | null; + const element = document.elementFromPoint(x, y) as HTMLElement | null; if (!element) return null; + return pierceShadowDOM(element, x, y); +} - // Keep drilling down through shadow roots - while (element?.shadowRoot) { - const deeper = element.shadowRoot.elementFromPoint(x, y) as HTMLElement | null; - if (!deeper || deeper === element) break; - element = deeper; +// ============================================================================= +// Pierce mode (Cmd+hover) — scans through container wrappers and invisible +// elements to find the actual content underneath. Useful for annotation in +// animation-heavy frameworks (Remotion, Framer Motion, etc.) where empty +// overlay divs intercept pointer events. +// ============================================================================= + +const GENERIC_CONTAINER_TAGS = new Set([ + "DIV", "SPAN", "SECTION", "ARTICLE", "MAIN", "ASIDE", "HEADER", "FOOTER", "NAV", +]); + +function isEffectivelyInvisible(el: HTMLElement): boolean { + if (typeof el.checkVisibility === "function") { + return !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true }); + } + let current: HTMLElement | null = el; + while (current && current !== document.body) { + if (window.getComputedStyle(current).opacity === "0") return true; + current = current.parentElement; + } + return false; +} + +function hasDirectContent(el: HTMLElement): boolean { + if (!GENERIC_CONTAINER_TAGS.has(el.tagName)) return true; + for (const child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) return true; + } + return false; +} + +/** + * Pierce mode: scans all elements at a point to find the deepest one with + * visible, meaningful content. Two-pass approach: + * 1. Find elements with direct text content (skips empty wrappers) + * 2. If no text found, return the smallest visible element (catches + * visual-only elements like timeline bars, chart segments, swatches) + * Activated by holding Cmd (Mac) / Ctrl (Windows) while hovering. + */ +function pierceElementFromPoint(x: number, y: number): HTMLElement | null { + const topElement = document.elementFromPoint(x, y) as HTMLElement | null; + if (!topElement) return null; + + const pierced = pierceShadowDOM(topElement, x, y); + if (hasDirectContent(pierced) && !isEffectivelyInvisible(pierced)) + return pierced; + + const allElements = document.elementsFromPoint(x, y) as HTMLElement[]; + + // Pass 1: find first element with direct text content + for (const candidate of allElements) { + if (candidate === topElement) continue; + if (candidate === document.documentElement || candidate === document.body) + continue; + const deep = pierceShadowDOM(candidate, x, y); + if (hasDirectContent(deep) && !isEffectivelyInvisible(deep)) + return deep; } - return element; + // Pass 2: no text content found — return the smallest visible element. + // Catches visual-only elements (timeline bars, color swatches, progress + // indicators, chart segments) that communicate through color/size, not text. + const topRect = pierced.getBoundingClientRect(); + const topArea = topRect.width * topRect.height; + let smallest: HTMLElement | null = null; + let smallestArea = topArea; + + for (const candidate of allElements) { + if (candidate === topElement) continue; + if (candidate === document.documentElement || candidate === document.body) + continue; + if (isEffectivelyInvisible(candidate)) continue; + const deep = pierceShadowDOM(candidate, x, y); + const rect = deep.getBoundingClientRect(); + const area = rect.width * rect.height; + if (area > 0 && area < smallestArea) { + smallest = deep; + smallestArea = area; + } + } + + return smallest || pierced; } function isElementFixed(element: HTMLElement): boolean { @@ -1499,7 +1591,11 @@ export function PageFeedbackToolbarCSS({ return; } - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + // Cmd (Mac) / Ctrl (Win) = pierce mode: scan through overlays + const piercing = e.metaKey || e.ctrlKey; + const elementUnder = piercing + ? pierceElementFromPoint(e.clientX, e.clientY) + : deepElementFromPoint(e.clientX, e.clientY); if ( !elementUnder || closestCrossingShadow(elementUnder, "[data-feedback-toolbar]") @@ -1518,6 +1614,7 @@ export function PageFeedbackToolbarCSS({ elementPath: path, rect, reactComponents, + isPiercing: piercing, }); setHoverPosition({ x: e.clientX, y: e.clientY }); }; @@ -1544,11 +1641,12 @@ export function PageFeedbackToolbarCSS({ if (closestCrossingShadow(target, "[data-annotation-marker]")) return; // Handle cmd+shift+click for multi-element selection + // Cmd is held so pierce mode is active — use pierceElementFromPoint if (e.metaKey && e.shiftKey && !pendingAnnotation && !editingAnnotation) { e.preventDefault(); e.stopPropagation(); - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + const elementUnder = pierceElementFromPoint(e.clientX, e.clientY); if (!elementUnder) return; const rect = elementUnder.getBoundingClientRect(); @@ -1615,7 +1713,11 @@ export function PageFeedbackToolbarCSS({ e.preventDefault(); - const elementUnder = deepElementFromPoint(e.clientX, e.clientY); + // Cmd (Mac) / Ctrl (Win) without Shift = pierce mode click + const piercing = (e.metaKey || e.ctrlKey) && !e.shiftKey; + const elementUnder = piercing + ? pierceElementFromPoint(e.clientX, e.clientY) + : deepElementFromPoint(e.clientX, e.clientY); if (!elementUnder) return; const { name, path, reactComponents } = identifyElementWithReact( @@ -3862,6 +3964,7 @@ export function PageFeedbackToolbarCSS({ height: hoverInfo.rect.height, borderColor: `${settings.annotationColor}80`, backgroundColor: `${settings.annotationColor}0A`, + ...(hoverInfo.isPiercing ? { borderStyle: "dashed" } : {}), }} /> )} @@ -3993,11 +4096,16 @@ export function PageFeedbackToolbarCSS({ Math.min(hoverPosition.x, window.innerWidth - 100), ), top: Math.max( - hoverPosition.y - (hoverInfo.reactComponents ? 48 : 32), + hoverPosition.y - (hoverInfo.isPiercing ? 62 : hoverInfo.reactComponents ? 48 : 32), 8, ), }} > + {hoverInfo.isPiercing && ( +
+ {"⇣ deep select"} +
+ )} {hoverInfo.reactComponents && (
{hoverInfo.reactComponents} diff --git a/package/src/components/page-toolbar-css/styles.module.scss b/package/src/components/page-toolbar-css/styles.module.scss index ea5102ee..c15ce761 100644 --- a/package/src/components/page-toolbar-css/styles.module.scss +++ b/package/src/components/page-toolbar-css/styles.module.scss @@ -833,6 +833,13 @@ $green: #34c759; } } +.hoverPierceIndicator { + font-size: 0.5625rem; + color: rgba(255, 255, 255, 0.45); + margin-bottom: 0.1rem; + letter-spacing: 0.03em; +} + .hoverReactPath { font-size: 0.625rem; color: rgba(255, 255, 255, 0.6); diff --git a/package/src/utils/element-identification.ts b/package/src/utils/element-identification.ts index f86eb97e..e505ce8b 100644 --- a/package/src/utils/element-identification.ts +++ b/package/src/utils/element-identification.ts @@ -97,6 +97,21 @@ export function getElementPath(target: HTMLElement, maxDepth = 4): string { return parts.join(" > "); } +/** + * Gets concatenated direct text node content from an element. + * Only includes immediate text nodes, not text from child elements. + */ +function getDirectTextContent(el: HTMLElement): string { + let text = ""; + for (const child of el.childNodes) { + if (child.nodeType === Node.TEXT_NODE) { + const t = child.textContent?.trim(); + if (t) text += (text ? " " : "") + t; + } + } + return text; +} + /** * Identifies an element and returns a human-readable name + path */ @@ -200,6 +215,12 @@ export function identifyElement(target: HTMLElement): { name: string; path: stri if (ariaLabel) return { name: `${tag} [${ariaLabel}]`, path }; if (role) return { name: `${role}`, path }; + // Prefer direct text content over class names — "$54" is more useful than "styles productPrice" + const directText = getDirectTextContent(target); + if (directText && directText.length < 50) { + return { name: `"${directText}"`, path }; + } + if (typeof className === "string" && className) { const words = className .split(/[\s_-]+/) diff --git a/package/src/utils/react-detection.ts b/package/src/utils/react-detection.ts index a50fe10f..45aea4de 100644 --- a/package/src/utils/react-detection.ts +++ b/package/src/utils/react-detection.ts @@ -87,6 +87,9 @@ export const DEFAULT_SKIP_EXACT = new Set([ "ErrorBoundaryHandler", "HotReload", "Hot", + // Video framework internals + "SeriesSequence", + "AbsoluteFill", ]); /** @@ -112,6 +115,11 @@ export const DEFAULT_SKIP_PATTERNS: RegExp[] = [ /^With[A-Z]/, // withRouter, WithAuth (HOCs) /Wrapper$/, // Generic wrappers /^Root$/, // Generic Root component + // React internal wrappers + /RefForwarding/, // React.forwardRef wrapper names + // Video framework internals (Remotion, etc.) + /^Remotion/, // Remotion-prefixed internals + /^TransitionSeries/, // TransitionSeries wrappers ]; /** From e108a7e8d756c78654c21c35f714d13587001676 Mon Sep 17 00:00:00 2001 From: Steve Smith Date: Tue, 10 Mar 2026 23:19:51 +0000 Subject: [PATCH 2/2] Polish deep select: instant Cmd response, remove pierce label, redesign demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix delay: add keydown/keyup listeners so pressing Cmd instantly re-evaluates hover without requiring mouse movement - Same element: when pierce finds same element as normal hover, suppress pierce indicator (solid border stays) - Remove arrow label: delete "⇣ deep select" text and hoverPierceIndicator CSS — dashed outline is sufficient - Redesign demo: replace inbox scenario with landing page hero + motion.div wrapper, gradient CTA, social proof, entrance animation - Add dual cursor (crosshair/pointer) and human-like popup interaction - Add label hint on wrapper boundary --- .../src/app/components/DeepSelectDemo.css | 241 +++++++++-------- .../src/app/components/DeepSelectDemo.tsx | 244 ++++++++++-------- package/example/src/app/features/page.tsx | 2 +- .../src/components/page-toolbar-css/index.tsx | 65 +++-- .../page-toolbar-css/styles.module.scss | 7 - 5 files changed, 316 insertions(+), 243 deletions(-) diff --git a/package/example/src/app/components/DeepSelectDemo.css b/package/example/src/app/components/DeepSelectDemo.css index 6f8719ca..bf26a7a3 100644 --- a/package/example/src/app/components/DeepSelectDemo.css +++ b/package/example/src/app/components/DeepSelectDemo.css @@ -1,162 +1,185 @@ /* ───────────────────────────────────────────────────────── - * Deep Select Demo + * Deep Select Demo — Landing page hero scenario * Reuses shared demo-window / demo-browser-bar / demo-content * from FeaturesDemo.css * ───────────────────────────────────────────────────────── */ -/* Page layout — sidebar + main */ -.dsd-page-layout { +/* Mini landing page layout */ +.dsd-page { display: flex; - gap: 12px; + flex-direction: column; height: 100%; + background: white; } -.dsd-sidebar { +/* Nav bar */ +.dsd-nav { display: flex; - flex-direction: column; - gap: 6px; - padding-top: 2px; + align-items: center; + padding: 10px 16px; + gap: 12px; + border-bottom: 1px solid rgba(0,0,0,0.04); } -.dsd-sidebar-item { - width: 28px; - height: 28px; - border-radius: 6px; - background: rgba(0,0,0,0.06); +.dsd-logo { + font-size: 11px; + font-weight: 700; + color: rgba(0,0,0,0.8); + font-family: system-ui, sans-serif; + letter-spacing: -0.3px; + display: flex; + align-items: center; + gap: 5px; } -.dsd-sidebar-item.active { - background: rgba(0,0,0,0.12); +.dsd-logo-mark { + width: 14px; + height: 14px; + background: linear-gradient(135deg, #6366f1, #3b82f6); + border-radius: 4px; } -.dsd-main { - flex: 1; - min-width: 0; +.dsd-nav-links { + display: flex; + gap: 12px; + margin-left: auto; } -.dsd-faux-title { - width: 90px; - height: 8px; - background: rgba(0,0,0,0.1); - border-radius: 4px; - margin-bottom: 10px; +.dsd-nav-link { + font-size: 10px; + font-weight: 500; + color: rgba(0,0,0,0.45); + font-family: system-ui, sans-serif; } -/* Dashboard card */ -.dsd-card { +/* Hero section — the animation wrapper covers this */ +.dsd-hero-wrapper { position: relative; - background: white; - border-radius: 10px; - padding: 12px 14px; - box-shadow: 0 1px 3px rgba(0,0,0,0.06); -} - -.dsd-card-header { + flex: 1; display: flex; align-items: center; - gap: 6px; - margin-bottom: 6px; + justify-content: center; + background: linear-gradient(180deg, #f8faff 0%, #f0f4ff 100%); } -.dsd-card-icon { - width: 24px; - height: 24px; - background: #3b82f6; - border-radius: 6px; - opacity: 0.15; +/* Invisible animation overlay — the problem this feature solves */ +.dsd-overlay { + position: absolute; + inset: 0; + z-index: 2; + background: rgba(239, 68, 68, 0); + transition: background 0.3s ease; } -.dsd-card-label { - font-size: 11px; +.dsd-overlay.flash { + background: rgba(239, 68, 68, 0.03); +} + +/* Wrapper label hint — shows motion.div boundary */ +.dsd-wrapper-label { + position: absolute; + top: 6px; + right: 8px; + font-size: 8px; font-weight: 500; - color: rgba(0,0,0,0.4); - font-family: system-ui, sans-serif; + font-family: 'SF Mono', SFMono-Regular, ui-monospace, monospace; + color: rgba(0,0,0,0.15); + z-index: 1; + pointer-events: none; + letter-spacing: 0.2px; } -.dsd-card-value { +/* Hero content — entrance animation mimics what motion.div actually does */ +.dsd-hero { + text-align: center; + padding: 0 24px; + opacity: 0; + transform: translateY(12px) scale(0.97); + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.dsd-hero.entered { + opacity: 1; + transform: translateY(0) scale(1); +} + +.dsd-heading { font-size: 20px; - font-weight: 700; - color: rgba(0,0,0,0.8); + font-weight: 750; + color: rgba(0,0,0,0.88); font-family: system-ui, sans-serif; + letter-spacing: -0.6px; + line-height: 1.15; margin-bottom: 8px; - letter-spacing: -0.5px; } -/* Mini bar chart — stronger opacity so bars read as data */ -.dsd-chart { - display: flex; - align-items: flex-end; - gap: 4px; - height: 28px; - margin-bottom: 10px; -} - -.dsd-chart-bar { - flex: 1; - background: #3b82f6; - border-radius: 3px 3px 0 0; - opacity: 0.22; +.dsd-heading-accent { + background: linear-gradient(135deg, #6366f1, #3b82f6); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; } -.dsd-chart-bar:nth-child(n+5) { - opacity: 0.35; +.dsd-subtitle { + font-size: 10.5px; + color: rgba(0,0,0,0.4); + font-family: system-ui, sans-serif; + line-height: 1.5; + margin-bottom: 16px; } -/* Export button — the target element */ -.dsd-export-btn { - display: inline-block; - padding: 6px 14px; - background: rgba(0,0,0,0.8); +/* CTA button — the target element */ +.dsd-cta { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 8px 18px; + background: linear-gradient(135deg, #6366f1, #4f46e5); color: white; - font-size: 10px; + border-radius: 8px; + font-size: 10.5px; font-weight: 600; font-family: system-ui, sans-serif; - border-radius: 6px; + letter-spacing: -0.1px; + box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3); } -/* Secondary metric cards below the main card */ -.dsd-mini-cards { - display: flex; - gap: 6px; - margin-top: 6px; +.dsd-cta-arrow { + font-size: 11px; + opacity: 0.7; } -.dsd-mini-card { - flex: 1; - background: white; - border-radius: 8px; - padding: 8px 10px; - box-shadow: 0 1px 3px rgba(0,0,0,0.06); +/* Social proof row */ +.dsd-social-proof { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 14px; } -.dsd-mini-label { - font-size: 9px; - font-weight: 500; - color: rgba(0,0,0,0.35); - font-family: system-ui, sans-serif; - margin-bottom: 2px; +.dsd-avatars { + display: flex; } -.dsd-mini-value { - font-size: 14px; - font-weight: 700; - color: rgba(0,0,0,0.7); - font-family: system-ui, sans-serif; - letter-spacing: -0.3px; +.dsd-mini-avatar { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid white; + margin-left: -4px; } -/* Invisible overlay — the problem this feature solves */ -.dsd-overlay { - position: absolute; - inset: 0; - border-radius: 10px; - z-index: 2; - background: rgba(239, 68, 68, 0); - transition: background 0.3s ease; +.dsd-mini-avatar:first-child { + margin-left: 0; } -.dsd-overlay.flash { - background: rgba(239, 68, 68, 0.05); +.dsd-social-text { + font-size: 8.5px; + color: rgba(0,0,0,0.3); + font-family: system-ui, sans-serif; + font-weight: 500; } /* Hover highlight */ @@ -211,11 +234,3 @@ background: rgba(0,0,0,0.85); color: rgba(255,255,255,0.95); } - -/* Pierce label on tooltip — readable, not decorative */ -.ds-pierce-label { - font-size: 9px; - color: rgba(255,255,255,0.5); - letter-spacing: 0.03em; - margin-bottom: 1px; -} diff --git a/package/example/src/app/components/DeepSelectDemo.tsx b/package/example/src/app/components/DeepSelectDemo.tsx index a876b97e..23723f90 100644 --- a/package/example/src/app/components/DeepSelectDemo.tsx +++ b/package/example/src/app/components/DeepSelectDemo.tsx @@ -11,30 +11,33 @@ function delay(ms: number) { /* ───────────────────────────────────────────────────────── * ANIMATION STORYBOARD * - * 0ms reset — cursor top-right, caption: idle - * 600ms cursor moves toward "Export" button - * 1000ms NORMAL: solid highlight on entire card + overlay flash - * → tooltip: div.AnimatePresence (wrong/dimmed) - * → caption: "Normal hover selects the invisible wrapper." - * 2600ms highlight + tooltip fade out - * 3000ms caption: "Hold ⌘ to pierce through overlay layers." - * 5000ms PIERCE: dashed highlight on just the button - * → tooltip: button "Export" (correct/bright) - * → caption: "Deep select finds the actual element underneath." - * 6400ms popup appears, types "Add loading state" - * 8200ms popup closes, marker placed - * 10200ms marker fades, loop + * 0ms reset — hero content hidden, cursor top-right + * 200ms hero entrance (fade up + scale — the motion.div animation) + * 800ms crosshair cursor moves toward CTA button + * 1200ms NORMAL: solid highlight on entire hero wrapper + * → tooltip: div.motion-container (wrong/dimmed) + * → overlay flash + * 2800ms highlight + tooltip fade out + * 3000ms caption: "Hold ⌘ to select through invisible layers." + * 4600ms PIERCE: dashed highlight on just the CTA button + * → tooltip: button "Get Started" (correct/bright) + * 6000ms click — popup appears, highlight/tooltip hide + * → cursor switches to pointer, moves to input area + * 6600ms typing feedback + * 7800ms cursor moves to Add button + * 8200ms click Add — popup closes, marker placed + * → cursor switches back to crosshair + *10200ms marker fades, loop * ───────────────────────────────────────────────────────── */ const LOOP_INTERVAL = 11800; -const CHART_HEIGHTS = [45, 60, 35, 75, 55, 90, 70, 85]; type CaptionKey = "idle" | "cmd" | "correct"; const CAPTIONS: Record = { - idle: "Normal hover selects the invisible animation wrapper.", - cmd: "Hold \u2318 to pierce through overlay layers.", - correct: "Deep select finds the actual element underneath.", + idle: "Animation wrappers intercept hover on the element you want.", + cmd: "Hold \u2318 to select through invisible layers.", + correct: "Deep select finds what\u2019s actually underneath.", }; export function DeepSelectDemo() { @@ -59,26 +62,30 @@ export function DeepSelectDemo() { const [typedText, setTypedText] = useState(""); const [showMarker, setShowMarker] = useState(false); const [overlayFlash, setOverlayFlash] = useState(false); + const [heroEntered, setHeroEntered] = useState(false); + const [isCrosshair, setIsCrosshair] = useState(true); - const cardRef = useRef(null); - const btnRef = useRef(null); + const wrapperRef = useRef(null); + const ctaRef = useRef(null); const contentRef = useRef(null); + const addBtnRef = useRef(null); + const addBtnPosRef = useRef({ x: 0, y: 0 }); - const cardPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); - const btnPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + const wrapperPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); + const ctaPosRef = useRef({ x: 0, y: 0, w: 0, h: 0 }); const measure = () => { - if (!cardRef.current || !btnRef.current || !contentRef.current) return; + if (!wrapperRef.current || !ctaRef.current || !contentRef.current) return; const cRect = contentRef.current.getBoundingClientRect(); - const cardRect = cardRef.current.getBoundingClientRect(); - const bRect = btnRef.current.getBoundingClientRect(); - cardPosRef.current = { - x: cardRect.left - cRect.left, - y: cardRect.top - cRect.top, - w: cardRect.width, - h: cardRect.height, + const wRect = wrapperRef.current.getBoundingClientRect(); + const bRect = ctaRef.current.getBoundingClientRect(); + wrapperPosRef.current = { + x: wRect.left - cRect.left, + y: wRect.top - cRect.top, + w: wRect.width, + h: wRect.height, }; - btnPosRef.current = { + ctaPosRef.current = { x: bRect.left - cRect.left, y: bRect.top - cRect.top, w: bRect.width, @@ -97,7 +104,7 @@ export function DeepSelectDemo() { useEffect(() => { let cancelled = false; - const feedbackText = "Add loading state"; + const feedbackText = "Add hover state"; const run = async () => { // Reset @@ -108,67 +115,82 @@ export function DeepSelectDemo() { setTypedText(""); setShowMarker(false); setOverlayFlash(false); + setHeroEntered(false); + setIsCrosshair(true); setActiveCaption("idle"); + await delay(200); + if (cancelled) return; + + // Hero entrance — the motion.div animation + setHeroEntered(true); await delay(600); if (cancelled) return; - // Re-measure before using positions (layout may have shifted) + // Re-measure after entrance (content is now in final position) measure(); - const card = cardPosRef.current; - const btn = btnPosRef.current; - setCursorPos({ x: btn.x + btn.w / 2, y: btn.y + btn.h / 2 }); + const wrapper = wrapperPosRef.current; + const cta = ctaPosRef.current; + setCursorPos({ x: cta.x + cta.w / 2 - 8.5, y: cta.y + cta.h / 2 - 8.5 }); await delay(400); if (cancelled) return; - // Normal hover — highlights the whole card (the overlay intercepts) + // Normal hover — highlights the entire hero wrapper (animation container intercepts) setOverlayFlash(true); setHighlight({ visible: true, mode: "normal", - rect: { x: card.x - 3, y: card.y - 3, w: card.w + 6, h: card.h + 6 }, + rect: { x: wrapper.x - 3, y: wrapper.y - 3, w: wrapper.w + 6, h: wrapper.h + 6 }, }); setTooltip({ visible: true, - text: "div.AnimatePresence", + text: "div.motion-container", type: "wrong", - x: card.x + card.w / 2, - y: card.y - 10, + x: wrapper.x + wrapper.w / 2, + y: wrapper.y - 10, }); await delay(1600); if (cancelled) return; - // Fade highlight + tooltip + overlay flash + // Fade out setHighlight((h) => ({ ...h, visible: false })); setTooltip((t) => ({ ...t, visible: false })); setOverlayFlash(false); await delay(400); if (cancelled) return; - // ⌘ beat — caption explains the feature + // ⌘ beat setActiveCaption("cmd"); - await delay(2000); + await delay(1600); if (cancelled) return; - // Pierce hover — highlights just the button + // Pierce hover — highlights just the CTA button setActiveCaption("correct"); setHighlight({ visible: true, mode: "pierce", - rect: { x: btn.x - 3, y: btn.y - 3, w: btn.w + 6, h: btn.h + 6 }, + rect: { x: cta.x - 3, y: cta.y - 3, w: cta.w + 6, h: cta.h + 6 }, }); setTooltip({ visible: true, - text: 'button "Export"', + text: 'button "Get Started"', type: "correct", - x: btn.x + btn.w / 2, - y: btn.y - 10, + x: cta.x + cta.w / 2, + y: cta.y - 10, }); await delay(1400); if (cancelled) return; - // Click — show popup + // Click — show popup, hide highlight/tooltip setShowPopup(true); + setHighlight((h) => ({ ...h, visible: false })); + setTooltip((t) => ({ ...t, visible: false })); + await delay(300); + if (cancelled) return; + + // Switch to pointer cursor, move to input area + setIsCrosshair(false); + setCursorPos({ x: 280, y: 100 }); await delay(300); if (cancelled) return; @@ -176,24 +198,34 @@ export function DeepSelectDemo() { for (let i = 0; i <= feedbackText.length; i++) { if (cancelled) return; setTypedText(feedbackText.slice(0, i)); - await delay(30); + await delay(35); } await delay(400); if (cancelled) return; - // Close popup, show marker + // Move cursor to Add button + if (addBtnRef.current && contentRef.current) { + const abr = addBtnRef.current.getBoundingClientRect(); + const cr = contentRef.current.getBoundingClientRect(); + addBtnPosRef.current = { x: abr.left - cr.left + abr.width / 2, y: abr.top - cr.top + abr.height / 2 }; + } + setCursorPos({ x: addBtnPosRef.current.x, y: addBtnPosRef.current.y }); + await delay(400); + if (cancelled) return; + + // Click Add — close popup, show marker, switch back to crosshair setShowPopup(false); - setHighlight((h) => ({ ...h, visible: false })); - setTooltip((t) => ({ ...t, visible: false })); + setIsCrosshair(true); await delay(200); if (cancelled) return; setShowMarker(true); - await delay(2200); + await delay(2000); if (cancelled) return; // Clean up for next loop setShowMarker(false); + setHighlight((h) => ({ ...h, visible: false })); await delay(300); }; @@ -227,48 +259,49 @@ export function DeepSelectDemo() {
-
localhost:3000/dashboard
+
localhost:3000
-
- {/* Sidebar nav */} -
-
-
-
-
+
+ {/* Nav */} +
+
+
+ Acme +
+
+ Features + Pricing +
- {/* Main content area */} -
-
- -
- {/* Invisible overlay — the problem */} -
-
-
-
Monthly Revenue
+ {/* Hero wrapper — the animation container */} +
+ {/* Invisible animation overlay — the problem */} +
+
<motion.div>
+ +
+
+ Ship faster with +
+ better feedback
-
$12.4k
-
- {CHART_HEIGHTS.map((h, i) => ( -
- ))} +
+ The modern way to collect design annotations.
-
Export
-
- - {/* Secondary metric cards */} -
-
-
Users
-
1,847
+
+ Get Started
-
-
Conversion
-
3.2%
+
+
+
+
+
+
+
+ Trusted by 500+ teams
@@ -290,19 +323,18 @@ export function DeepSelectDemo() { className={`ds-tooltip ${tooltip.visible ? "visible" : ""} ${tooltip.type}`} style={{ left: tooltip.x, top: tooltip.y, transform: "translate(-50%, -100%)" }} > - {tooltip.type === "correct" &&
{"\u21E3"} deep select
} {tooltip.text}
{/* Popup */} -
-
button "Export"
+
+
button "Get Started"
{typedText}|
Cancel
-
Add
+
Add
@@ -310,19 +342,29 @@ export function DeepSelectDemo() {
1
- {/* Cursor — offset by half SVG size so crosshair center lands on target */} -
- - - - + {/* Cursor — dual mode like Computed Styles demo */} +
+
+ + + + + + +
+
+ + + + +
{/* Toolbar */} @@ -340,7 +382,7 @@ export function DeepSelectDemo() {
- {/* Caption — updates with animation state, matching SmartIdentificationDemo pattern */} + {/* Caption */}

{CAPTIONS[activeCaption]}

diff --git a/package/example/src/app/features/page.tsx b/package/example/src/app/features/page.tsx index bcbc6c0d..a7301e9e 100644 --- a/package/example/src/app/features/page.tsx +++ b/package/example/src/app/features/page.tsx @@ -87,7 +87,7 @@ export default function FeaturesPage() {

Deep select

Hold while hovering to pierce through invisible wrappers and select the actual element underneath. - Useful when animation libraries, video frameworks, or overlay patterns render empty divs on top of your content. + Useful when gesture libraries, animation frameworks, or overlay patterns render empty divs on top of your content.

diff --git a/package/src/components/page-toolbar-css/index.tsx b/package/src/components/page-toolbar-css/index.tsx index 407c8f9d..6ab82a04 100644 --- a/package/src/components/page-toolbar-css/index.tsx +++ b/package/src/components/page-toolbar-css/index.tsx @@ -1579,23 +1579,16 @@ export function PageFeedbackToolbarCSS({ }; }, [isActive]); - // Handle mouse move + // Handle mouse move + instant Cmd/Ctrl re-evaluation via keydown/keyup useEffect(() => { if (!isActive || pendingAnnotation) return; - const handleMouseMove = (e: MouseEvent) => { - // Use composedPath to get actual target inside shadow DOM - const target = (e.composedPath()[0] || e.target) as HTMLElement; - if (closestCrossingShadow(target, "[data-feedback-toolbar]")) { - setHoverInfo(null); - return; - } + const lastMouse = { x: 0, y: 0, hasMoved: false }; - // Cmd (Mac) / Ctrl (Win) = pierce mode: scan through overlays - const piercing = e.metaKey || e.ctrlKey; + const evaluateHover = (clientX: number, clientY: number, piercing: boolean) => { const elementUnder = piercing - ? pierceElementFromPoint(e.clientX, e.clientY) - : deepElementFromPoint(e.clientX, e.clientY); + ? pierceElementFromPoint(clientX, clientY) + : deepElementFromPoint(clientX, clientY); if ( !elementUnder || closestCrossingShadow(elementUnder, "[data-feedback-toolbar]") @@ -1604,6 +1597,15 @@ export function PageFeedbackToolbarCSS({ return; } + // When piercing, suppress indicator if result is same as normal hover + let effectivePiercing = piercing; + if (piercing) { + const normalElement = deepElementFromPoint(clientX, clientY); + if (normalElement === elementUnder) { + effectivePiercing = false; + } + } + const { name, elementName, path, reactComponents } = identifyElementWithReact(elementUnder, effectiveReactMode); const rect = elementUnder.getBoundingClientRect(); @@ -1614,13 +1616,39 @@ export function PageFeedbackToolbarCSS({ elementPath: path, rect, reactComponents, - isPiercing: piercing, + isPiercing: effectivePiercing, }); - setHoverPosition({ x: e.clientX, y: e.clientY }); + setHoverPosition({ x: clientX, y: clientY }); + }; + + const handleMouseMove = (e: MouseEvent) => { + // Use composedPath to get actual target inside shadow DOM + const target = (e.composedPath()[0] || e.target) as HTMLElement; + if (closestCrossingShadow(target, "[data-feedback-toolbar]")) { + setHoverInfo(null); + return; + } + lastMouse.x = e.clientX; + lastMouse.y = e.clientY; + lastMouse.hasMoved = true; + evaluateHover(e.clientX, e.clientY, e.metaKey || e.ctrlKey); + }; + + // Re-evaluate immediately when Cmd/Ctrl is pressed or released + const handleKeyChange = (e: KeyboardEvent) => { + if ((e.key === "Meta" || e.key === "Control") && lastMouse.hasMoved) { + evaluateHover(lastMouse.x, lastMouse.y, e.metaKey || e.ctrlKey); + } }; document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); + document.addEventListener("keydown", handleKeyChange); + document.addEventListener("keyup", handleKeyChange); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("keydown", handleKeyChange); + document.removeEventListener("keyup", handleKeyChange); + }; }, [isActive, pendingAnnotation, effectiveReactMode]); // Handle click @@ -4096,16 +4124,11 @@ export function PageFeedbackToolbarCSS({ Math.min(hoverPosition.x, window.innerWidth - 100), ), top: Math.max( - hoverPosition.y - (hoverInfo.isPiercing ? 62 : hoverInfo.reactComponents ? 48 : 32), + hoverPosition.y - (hoverInfo.reactComponents ? 48 : 32), 8, ), }} > - {hoverInfo.isPiercing && ( -
- {"⇣ deep select"} -
- )} {hoverInfo.reactComponents && (
{hoverInfo.reactComponents} diff --git a/package/src/components/page-toolbar-css/styles.module.scss b/package/src/components/page-toolbar-css/styles.module.scss index c15ce761..ea5102ee 100644 --- a/package/src/components/page-toolbar-css/styles.module.scss +++ b/package/src/components/page-toolbar-css/styles.module.scss @@ -833,13 +833,6 @@ $green: #34c759; } } -.hoverPierceIndicator { - font-size: 0.5625rem; - color: rgba(255, 255, 255, 0.45); - margin-bottom: 0.1rem; - letter-spacing: 0.03em; -} - .hoverReactPath { font-size: 0.625rem; color: rgba(255, 255, 255, 0.6);