Created
January 12, 2026 02:32
-
-
Save utxo-detective/c80e8cd2abbf54d7fa39dff11663f0c7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name React Component Picker+ (Deep Context + Multi-Select) | |
| // @namespace https://example.local/ | |
| // @version 0.3.0 | |
| // @description Pick DOM/React components via Fiber and copy rich context for LLMs. Cmd/Ctrl+C toggles picker; Cmd/Ctrl+Shift+C copies context. | |
| // @match https://app.hyperliquid.xyz/* | |
| // @match https://*.your-domain.com/* | |
| // @match http://localhost:*/* | |
| // @match http://127.0.0.1:*/* | |
| // @grant GM_setClipboard | |
| // @grant GM_addStyle | |
| // @run-at document-idle | |
| // @match https://app.hyperliquid.xyz/* | |
| // @match https://app.based.one/* | |
| // @match https://x.com/* | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| // ---------------------------- | |
| // Config | |
| // ---------------------------- | |
| const CFG = { | |
| // Toggle picker mode: Cmd/Ctrl + C | |
| toggleHotkey: { code: "KeyC", requireMetaOrCtrl: true, shiftKey: false }, | |
| // Copy context: Cmd/Ctrl + Shift + C | |
| copyHotkey: { code: "KeyC", requireMetaOrCtrl: true, shiftKey: true }, | |
| // Limits | |
| maxHtmlChars: 12000, | |
| maxPropsChars: 14000, | |
| maxStackDepth: 14, | |
| maxHookItems: 14, | |
| maxFiberTreeDepth: 5, | |
| maxFiberTreeBreadth: 24, | |
| maxComponentDomRoots: 10, | |
| maxHostNodesScan: 700, | |
| // Deep capture (nested components / dropdowns) | |
| deep: { | |
| maxInteractiveNodes: 30, | |
| maxInteractiveHtmlChars: 2500, | |
| maxNotableComponents: 30, | |
| maxNotablePropsKeys: 40, | |
| includeComputedStyle: false, // keep false; can be heavy | |
| }, | |
| // Heuristics | |
| primitiveNames: new Set([ | |
| "Box", "Flex", "Grid", "Stack", "HStack", "VStack", | |
| "Text", "Heading", "Image", "Icon", "Button", | |
| "styled.div", "styled.span", "styled.button", "styled.a", | |
| "ForwardRef", "Memo", "Fragment", | |
| ]), | |
| primitiveNamePrefixes: ["styled.", "sc-", "emotion", "css-"], | |
| ignoreMinifiedNamesShorterThan: 2, | |
| }; | |
| // ---------------------------- | |
| // CSS | |
| // ---------------------------- | |
| const css = ` | |
| .rcp__root { position: fixed; inset: 0; pointer-events: none; z-index: 2147483647; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; } | |
| .rcp__hud { position: fixed; bottom: 16px; right: 16px; pointer-events: auto; background: rgba(20,20,20,.92); color: #fff; border-radius: 10px; padding: 10px 12px; box-shadow: 0 10px 30px rgba(0,0,0,.35); width: 420px; display: none; } | |
| .rcp__hud.rcp__hud--open { display: block; } | |
| .rcp__row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } | |
| .rcp__title { font-weight: 650; font-size: 13px; } | |
| .rcp__pill { font-size: 12px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.12); } | |
| .rcp__btns { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; } | |
| .rcp__btn { cursor: pointer; user-select: none; border: 0; padding: 8px 10px; border-radius: 8px; font-size: 12px; background: rgba(255,255,255,.12); color: #fff; } | |
| .rcp__btn:hover { background: rgba(255,255,255,.18); } | |
| .rcp__btn--primary { background: rgba(59,130,246,.85); } | |
| .rcp__btn--primary:hover { background: rgba(59,130,246,1); } | |
| .rcp__meta { margin-top: 10px; font-size: 12px; line-height: 1.35; color: rgba(255,255,255,.88); max-height: 240px; overflow: auto; white-space: pre-wrap; } | |
| .rcp__kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New"; font-size: 11px; color: rgba(255,255,255,.8); } | |
| .rcp__hint { margin-top: 8px; font-size: 11px; color: rgba(255,255,255,.7); } | |
| .rcp__highlight { position: fixed; pointer-events: none; border: 2px solid rgba(59,130,246,.95); border-radius: 6px; box-shadow: 0 0 0 3px rgba(59,130,246,.18); display: none; } | |
| .rcp__tooltip { position: fixed; pointer-events: none; background: rgba(20,20,20,.92); color: #fff; border-radius: 8px; padding: 6px 8px; font-size: 12px; max-width: min(640px, 90vw); display: none; box-shadow: 0 10px 30px rgba(0,0,0,.35); } | |
| .rcp__tooltip .rcp__mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New"; font-size: 11px; opacity: .9; } | |
| .rcp__opts { margin-top: 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .rcp__opt { display: flex; align-items: center; gap: 8px; font-size: 12px; color: rgba(255,255,255,.88); } | |
| .rcp__opt input { width: 14px; height: 14px; } | |
| .rcp__list { margin-top: 8px; font-size: 11px; color: rgba(255,255,255,.78); max-height: 110px; overflow: auto; border-top: 1px solid rgba(255,255,255,.10); padding-top: 8px; } | |
| .rcp__item { margin-bottom: 6px; } | |
| .rcp__item strong { color: rgba(255,255,255,.92); } | |
| `; | |
| if (typeof GM_addStyle === "function") GM_addStyle(css); | |
| else { | |
| const style = document.createElement("style"); | |
| style.textContent = css; | |
| document.documentElement.appendChild(style); | |
| } | |
| // ---------------------------- | |
| // UI elements | |
| // ---------------------------- | |
| const root = document.createElement("div"); | |
| root.className = "rcp__root"; | |
| document.documentElement.appendChild(root); | |
| const highlight = document.createElement("div"); | |
| highlight.className = "rcp__highlight"; | |
| root.appendChild(highlight); | |
| const tooltip = document.createElement("div"); | |
| tooltip.className = "rcp__tooltip"; | |
| root.appendChild(tooltip); | |
| const hud = document.createElement("div"); | |
| hud.className = "rcp__hud"; | |
| hud.innerHTML = ` | |
| <div class="rcp__row"> | |
| <div class="rcp__title">React Component Picker+</div> | |
| <div class="rcp__pill" id="rcpModePill">OFF</div> | |
| </div> | |
| <div class="rcp__hint"> | |
| Toggle: <span class="rcp__kbd">Cmd/Ctrl+C</span> • Copy: <span class="rcp__kbd">Cmd/Ctrl+Shift+C</span> • Add: <span class="rcp__kbd">Shift+Click</span> • Close: <span class="rcp__kbd">Esc</span> | |
| </div> | |
| <div class="rcp__opts"> | |
| <label class="rcp__opt"><input type="checkbox" id="rcpPreferSemantic" checked /> Prefer semantic component</label> | |
| <label class="rcp__opt"><input type="checkbox" id="rcpDeepCapture" checked /> Deep capture (dropdowns)</label> | |
| <label class="rcp__opt"><input type="checkbox" id="rcpMultiSelect" checked /> Multi-select</label> | |
| <label class="rcp__opt"><input type="checkbox" id="rcpIncludeFiberTree" /> Include fiber tree</label> | |
| </div> | |
| <div class="rcp__btns"> | |
| <button class="rcp__btn rcp__btn--primary" id="rcpCopyBtn">Copy context</button> | |
| <button class="rcp__btn" id="rcpCopyHtmlBtn">Copy HTML only</button> | |
| <button class="rcp__btn" id="rcpCopyJsonBtn">Copy JSON only</button> | |
| <button class="rcp__btn" id="rcpSelectParentBtn">Select parent semantic (↑)</button> | |
| <button class="rcp__btn" id="rcpClearBtn">Clear</button> | |
| </div> | |
| <div class="rcp__meta" id="rcpMeta"></div> | |
| <div class="rcp__list" id="rcpList" style="display:none;"></div> | |
| `; | |
| root.appendChild(hud); | |
| const modePill = hud.querySelector("#rcpModePill"); | |
| const metaEl = hud.querySelector("#rcpMeta"); | |
| const listEl = hud.querySelector("#rcpList"); | |
| const preferSemanticEl = hud.querySelector("#rcpPreferSemantic"); | |
| const deepCaptureEl = hud.querySelector("#rcpDeepCapture"); | |
| const multiSelectEl = hud.querySelector("#rcpMultiSelect"); | |
| const includeFiberTreeEl = hud.querySelector("#rcpIncludeFiberTree"); | |
| const copyBtn = hud.querySelector("#rcpCopyBtn"); | |
| const copyHtmlBtn = hud.querySelector("#rcpCopyHtmlBtn"); | |
| const copyJsonBtn = hud.querySelector("#rcpCopyJsonBtn"); | |
| const selectParentBtn = hud.querySelector("#rcpSelectParentBtn"); | |
| const clearBtn = hud.querySelector("#rcpClearBtn"); | |
| // ---------------------------- | |
| // State | |
| // ---------------------------- | |
| let enabled = false; | |
| let hoveredEl = null; | |
| /** @type {Array<{el: Element, ctx: any}>} */ | |
| let selections = []; | |
| // ---------------------------- | |
| // Helpers | |
| // ---------------------------- | |
| const isEditableTarget = (t) => { | |
| if (!t) return false; | |
| const tag = (t.tagName || "").toLowerCase(); | |
| return tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable === true; | |
| }; | |
| const hasTextSelection = () => { | |
| const sel = window.getSelection?.(); | |
| return sel && String(sel).trim().length > 0; | |
| }; | |
| const truncate = (s, max) => | |
| s && s.length > max ? s.slice(0, max) + `\n…(truncated, ${s.length} chars total)` : s; | |
| function safeStringify(value, maxChars) { | |
| const seen = new WeakSet(); | |
| const replacer = (k, v) => { | |
| if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`; | |
| if (v instanceof Element) return `[Element <${v.tagName.toLowerCase()}>]`; | |
| if (v instanceof Node) return `[Node ${v.nodeName}]`; | |
| if (typeof v === "object" && v !== null) { | |
| if (seen.has(v)) return "[Circular]"; | |
| seen.add(v); | |
| } | |
| return v; | |
| }; | |
| let out; | |
| try { | |
| out = JSON.stringify(value, replacer, 2); | |
| } catch (e) { | |
| out = `"[Unserializable: ${String(e)}]"`; | |
| } | |
| return truncate(out, maxChars); | |
| } | |
| function cssPath(el) { | |
| if (!el || el.nodeType !== 1) return ""; | |
| const parts = []; | |
| let cur = el; | |
| while (cur && cur.nodeType === 1 && parts.length < 7) { | |
| let part = cur.tagName.toLowerCase(); | |
| if (cur.id) { | |
| part += `#${cur.id}`; | |
| parts.unshift(part); | |
| break; | |
| } | |
| const cls = | |
| cur.className && typeof cur.className === "string" | |
| ? cur.className.trim().split(/\s+/).slice(0, 2) | |
| : []; | |
| if (cls.length) part += "." + cls.join("."); | |
| const parent = cur.parentElement; | |
| if (parent) { | |
| const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); | |
| if (siblings.length > 1) { | |
| const idx = siblings.indexOf(cur) + 1; | |
| part += `:nth-of-type(${idx})`; | |
| } | |
| } | |
| parts.unshift(part); | |
| cur = cur.parentElement; | |
| } | |
| return parts.join(" > "); | |
| } | |
| function elementSummary(el) { | |
| if (!el || el.nodeType !== 1) return null; | |
| const tag = el.tagName.toLowerCase(); | |
| const id = el.id || null; | |
| const classList = | |
| el.className && typeof el.className === "string" | |
| ? el.className.trim().split(/\s+/).filter(Boolean).slice(0, 14) | |
| : []; | |
| const role = el.getAttribute("role") || null; | |
| const dataTestId = el.getAttribute("data-testid") || el.getAttribute("data-test") || null; | |
| const ariaLabel = el.getAttribute("aria-label") || null; | |
| const ariaExpanded = el.getAttribute("aria-expanded") || null; | |
| const ariaHaspopup = el.getAttribute("aria-haspopup") || null; | |
| const dataState = el.getAttribute("data-state") || null; | |
| const textPreview = (el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 220) || null; | |
| let outerHTML = ""; | |
| try { outerHTML = el.outerHTML || ""; } catch { outerHTML = ""; } | |
| return { | |
| tag, id, classList, role, dataTestId, ariaLabel, ariaExpanded, ariaHaspopup, dataState, | |
| cssPath: cssPath(el), | |
| textPreview, | |
| outerHTML: truncate(outerHTML, CFG.maxHtmlChars), | |
| }; | |
| } | |
| // ---------------------------- | |
| // React Fiber extraction | |
| // ---------------------------- | |
| function getReactFiberFromDomNode(node) { | |
| if (!node) return null; | |
| const keys = Object.keys(node); | |
| for (const k of keys) { | |
| if (k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$")) return node[k]; | |
| } | |
| return null; | |
| } | |
| function getComponentNameFromType(type) { | |
| if (!type) return null; | |
| if (typeof type === "function") return type.displayName || type.name || "Anonymous"; | |
| if (typeof type === "object") { | |
| if (type.displayName) return type.displayName; | |
| if (type.type) return getComponentNameFromType(type.type); | |
| if (type.render) return getComponentNameFromType(type.render); | |
| } | |
| return null; | |
| } | |
| // Fiber tags (heuristic; stable enough across React versions) | |
| const FIBER_TAG = { | |
| FunctionComponent: 0, | |
| ClassComponent: 1, | |
| HostRoot: 3, | |
| HostComponent: 5, | |
| HostText: 6, | |
| Fragment: 7, | |
| ContextConsumer: 9, | |
| ContextProvider: 10, | |
| ForwardRef: 11, | |
| MemoComponent: 14, | |
| SimpleMemoComponent: 15, | |
| LazyComponent: 16, | |
| }; | |
| function isLikelyComponentFiber(fiber) { | |
| if (!fiber) return false; | |
| const t = fiber.tag; | |
| if ( | |
| t === FIBER_TAG.FunctionComponent || | |
| t === FIBER_TAG.ClassComponent || | |
| t === FIBER_TAG.ForwardRef || | |
| t === FIBER_TAG.MemoComponent || | |
| t === FIBER_TAG.SimpleMemoComponent || | |
| t === FIBER_TAG.LazyComponent | |
| ) return true; | |
| if (fiber.type && typeof fiber.type !== "string") return true; | |
| return false; | |
| } | |
| function normalizeName(name) { | |
| if (!name) return ""; | |
| return String(name).trim(); | |
| } | |
| function isPrimitiveName(name) { | |
| const n = normalizeName(name); | |
| if (!n) return true; | |
| if (CFG.primitiveNames.has(n)) return true; | |
| for (const p of CFG.primitiveNamePrefixes) { | |
| if (n.startsWith(p)) return true; | |
| } | |
| // Minified names like "a", "Et", "jt" | |
| if (n.length <= CFG.ignoreMinifiedNamesShorterThan) return true; | |
| return false; | |
| } | |
| function getNearestComponentFiber(fromFiber) { | |
| let f = fromFiber, guard = 0; | |
| while (f && guard++ < 240) { | |
| if (isLikelyComponentFiber(f)) return f; | |
| f = f.return; | |
| } | |
| return null; | |
| } | |
| // Walk up until we find something that is not just layout/primitive/minified | |
| function getNearestSemanticComponentFiber(fromFiber) { | |
| let f = fromFiber, guard = 0; | |
| while (f && guard++ < 340) { | |
| if (isLikelyComponentFiber(f)) { | |
| const name = getComponentNameFromType(f.type) || "(Unknown)"; | |
| if (!isPrimitiveName(name)) return f; | |
| } | |
| f = f.return; | |
| } | |
| // fallback: nearest component if nothing semantic found | |
| return getNearestComponentFiber(fromFiber); | |
| } | |
| function getComponentStack(componentFiber) { | |
| const stack = []; | |
| let f = componentFiber, guard = 0; | |
| while (f && guard++ < 500 && stack.length < CFG.maxStackDepth) { | |
| if (isLikelyComponentFiber(f)) { | |
| const name = getComponentNameFromType(f.type) || "(Unknown)"; | |
| const src = f._debugSource | |
| ? { fileName: f._debugSource.fileName, lineNumber: f._debugSource.lineNumber, columnNumber: f._debugSource.columnNumber } | |
| : null; | |
| stack.push({ name, source: src, tag: f.tag }); | |
| } | |
| f = f.return; | |
| } | |
| return stack; | |
| } | |
| function getHookSummary(componentFiber) { | |
| // Best-effort: hook chain is not stable API | |
| const out = []; | |
| let h = componentFiber?.memoizedState; | |
| let i = 0; | |
| while (h && i < CFG.maxHookItems) { | |
| out.push({ hookIndex: i, memoizedState: h.memoizedState }); | |
| h = h.next; | |
| i++; | |
| } | |
| return out; | |
| } | |
| function buildFiberTree(componentFiber, depth = 0) { | |
| if (!componentFiber || depth > CFG.maxFiberTreeDepth) return null; | |
| const name = | |
| getComponentNameFromType(componentFiber.type) || | |
| (componentFiber.tag === FIBER_TAG.HostComponent ? componentFiber.type : "(Unknown)"); | |
| const node = { name, tag: componentFiber.tag, children: [] }; | |
| let child = componentFiber.child; | |
| let count = 0; | |
| while (child && count++ < CFG.maxFiberTreeBreadth) { | |
| const interesting = | |
| isLikelyComponentFiber(child) || | |
| child.tag === FIBER_TAG.HostComponent || | |
| child.tag === FIBER_TAG.Fragment; | |
| if (interesting) node.children.push(buildFiberTree(child, depth + 1)); | |
| child = child.sibling; | |
| } | |
| return node; | |
| } | |
| function collectHostNodesUnderFiber(fiber) { | |
| const roots = []; | |
| const queue = []; | |
| const seen = new Set(); | |
| let scanned = 0; | |
| if (fiber?.child) queue.push(fiber.child); | |
| while (queue.length && scanned++ < CFG.maxHostNodesScan) { | |
| const f = queue.shift(); | |
| if (!f || seen.has(f)) continue; | |
| seen.add(f); | |
| if ((f.tag === FIBER_TAG.HostComponent || f.tag === FIBER_TAG.HostText) && f.stateNode) { | |
| roots.push(f.stateNode); | |
| } | |
| if (f.child) queue.push(f.child); | |
| if (f.sibling) queue.push(f.sibling); | |
| } | |
| const hostEls = roots.filter((n) => n && n.nodeType === 1); | |
| const unique = []; | |
| const seenEl = new Set(); | |
| for (const el of hostEls) { | |
| if (!seenEl.has(el)) { | |
| unique.push(el); | |
| seenEl.add(el); | |
| } | |
| if (unique.length >= CFG.maxComponentDomRoots) break; | |
| } | |
| return unique; | |
| } | |
| // ---------------------------- | |
| // Deep capture: interactive nodes + notable child components | |
| // ---------------------------- | |
| function isInteractiveHostFiber(f) { | |
| if (!f || f.tag !== FIBER_TAG.HostComponent) return false; | |
| const props = f.memoizedProps || {}; | |
| const keys = Object.keys(props); | |
| const hasReactHandler = keys.some((k) => /^on[A-Z]/.test(k) && typeof props[k] === "function"); | |
| const el = f.stateNode; | |
| if (!el || el.nodeType !== 1) return hasReactHandler; | |
| const tag = el.tagName.toLowerCase(); | |
| const role = el.getAttribute("role") || ""; | |
| const ariaHaspopup = el.getAttribute("aria-haspopup"); | |
| const ariaExpanded = el.getAttribute("aria-expanded"); | |
| const dataState = el.getAttribute("data-state"); | |
| const isClickableTag = tag === "button" || tag === "a" || tag === "summary" || tag === "input"; | |
| const looksInteractive = | |
| isClickableTag || | |
| role === "button" || | |
| role === "menuitem" || | |
| role === "listbox" || | |
| role === "combobox" || | |
| role === "option" || | |
| ariaHaspopup != null || | |
| ariaExpanded != null || | |
| dataState != null; | |
| return hasReactHandler || looksInteractive; | |
| } | |
| function summarizeHandlersFromProps(props) { | |
| const out = []; | |
| if (!props) return out; | |
| for (const [k, v] of Object.entries(props)) { | |
| if (/^on[A-Z]/.test(k) && typeof v === "function") out.push(k); | |
| } | |
| return out.sort(); | |
| } | |
| function pickNotableComponentName(name) { | |
| const n = normalizeName(name); | |
| if (!n) return false; | |
| const keywords = [ | |
| "Dropdown", "Select", "Menu", "Popover", "Dialog", "Modal", "Tooltip", | |
| "Combobox", "Listbox", "Option", "Tabs", "Tab", "Accordion", | |
| "OrderBook", "Orderbook", "Book", "Depth", "Table", "Row", | |
| "Input", "Checkbox", "Switch", "Radio", | |
| "Trigger", "Content", "Portal", | |
| ]; | |
| return keywords.some((k) => n.includes(k)); | |
| } | |
| function notablePropsKeys(props) { | |
| if (!props || typeof props !== "object") return []; | |
| const keys = Object.keys(props); | |
| const interesting = []; | |
| const keyHints = [ | |
| "open", "isOpen", "defaultOpen", | |
| "value", "defaultValue", "onChange", | |
| "onOpen", "onClose", "onSelect", "onClick", | |
| "items", "options", "children", "render", | |
| "side", "px", "price", "size", "sz", | |
| "cum", "depth", "max", "min", "step", | |
| "disabled", "loading", "active", "selected", | |
| "id", "label", "title", "name", | |
| ]; | |
| for (const k of keys) { | |
| if (interesting.length >= CFG.deep.maxNotablePropsKeys) break; | |
| if (keyHints.some((h) => k.toLowerCase().includes(h.toLowerCase()))) interesting.push(k); | |
| } | |
| // If none match, take first few stable keys (excluding giant "children" arrays) | |
| if (interesting.length === 0) { | |
| for (const k of keys) { | |
| if (interesting.length >= 12) break; | |
| if (k === "children") continue; | |
| interesting.push(k); | |
| } | |
| } | |
| return interesting; | |
| } | |
| function deepCaptureUnderFiber(rootFiber) { | |
| const interactive = []; | |
| const notableComponents = []; | |
| const queue = []; | |
| const seen = new Set(); | |
| if (rootFiber?.child) queue.push(rootFiber.child); | |
| while (queue.length) { | |
| const f = queue.shift(); | |
| if (!f || seen.has(f)) continue; | |
| seen.add(f); | |
| // interactive host nodes | |
| if (interactive.length < CFG.deep.maxInteractiveNodes && isInteractiveHostFiber(f)) { | |
| const el = f.stateNode; | |
| const props = f.memoizedProps || {}; | |
| const handlers = summarizeHandlersFromProps(props); | |
| let html = ""; | |
| if (el && el.nodeType === 1) { | |
| try { html = el.outerHTML || ""; } catch { html = ""; } | |
| } | |
| interactive.push({ | |
| cssPath: el && el.nodeType === 1 ? cssPath(el) : null, | |
| tag: el && el.nodeType === 1 ? el.tagName.toLowerCase() : null, | |
| role: el && el.nodeType === 1 ? (el.getAttribute("role") || null) : null, | |
| ariaLabel: el && el.nodeType === 1 ? (el.getAttribute("aria-label") || null) : null, | |
| ariaExpanded: el && el.nodeType === 1 ? (el.getAttribute("aria-expanded") || null) : null, | |
| ariaHaspopup: el && el.nodeType === 1 ? (el.getAttribute("aria-haspopup") || null) : null, | |
| dataState: el && el.nodeType === 1 ? (el.getAttribute("data-state") || null) : null, | |
| textPreview: el && el.nodeType === 1 ? ((el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 160) || null) : null, | |
| reactHandlers: handlers, | |
| // include non-function props shallowly (avoid huge dumps) | |
| propKeys: Object.keys(props).filter((k) => typeof props[k] !== "function").slice(0, 30), | |
| html: truncate(html, CFG.deep.maxInteractiveHtmlChars), | |
| componentStack: (() => { | |
| // stack from this host fiber up | |
| const st = []; | |
| let x = f.return, guard = 0; | |
| while (x && guard++ < 120 && st.length < 8) { | |
| if (isLikelyComponentFiber(x)) { | |
| const nm = getComponentNameFromType(x.type) || "(Unknown)"; | |
| st.push(nm); | |
| } | |
| x = x.return; | |
| } | |
| return st; | |
| })(), | |
| }); | |
| } | |
| // notable child component instances | |
| if (notableComponents.length < CFG.deep.maxNotableComponents && isLikelyComponentFiber(f)) { | |
| const name = getComponentNameFromType(f.type) || "(Unknown)"; | |
| if (pickNotableComponentName(name)) { | |
| const props = f.memoizedProps || null; | |
| const keys = notablePropsKeys(props); | |
| const picked = {}; | |
| for (const k of keys) { | |
| picked[k] = props?.[k]; | |
| } | |
| notableComponents.push({ | |
| name, | |
| propsPicked: safeStringify(picked, 4000), | |
| }); | |
| } | |
| } | |
| if (f.child) queue.push(f.child); | |
| if (f.sibling) queue.push(f.sibling); | |
| } | |
| return { interactive, notableComponents }; | |
| } | |
| // ---------------------------- | |
| // Context extraction | |
| // ---------------------------- | |
| function extractContextForElement(el, opts = {}) { | |
| const ctx = { | |
| page: { title: document.title, url: location.href }, | |
| selection: null, | |
| react: null, | |
| deep: null, | |
| notes: [], | |
| }; | |
| ctx.selection = elementSummary(el); | |
| if (!ctx.selection) { | |
| ctx.notes.push("No element selected."); | |
| return ctx; | |
| } | |
| const fiber = getReactFiberFromDomNode(el); | |
| if (!fiber) { | |
| ctx.notes.push("React Fiber not found on this element (page may not be React, or internals not reachable)."); | |
| return ctx; | |
| } | |
| const nearestComponent = getNearestComponentFiber(fiber); | |
| const semanticComponent = getNearestSemanticComponentFiber(fiber); | |
| const preferSemantic = opts.preferSemantic === true; | |
| const chosen = preferSemantic ? (semanticComponent || nearestComponent) : (nearestComponent || semanticComponent); | |
| if (!chosen) { | |
| ctx.notes.push("Found a Fiber node, but could not locate a nearby component Fiber."); | |
| return ctx; | |
| } | |
| const chosenName = getComponentNameFromType(chosen.type) || "(Unknown)"; | |
| const chosenPropsStr = safeStringify(chosen.memoizedProps, CFG.maxPropsChars); | |
| const nearestName = nearestComponent ? (getComponentNameFromType(nearestComponent.type) || "(Unknown)") : null; | |
| const semanticName = semanticComponent ? (getComponentNameFromType(semanticComponent.type) || "(Unknown)") : null; | |
| const stack = getComponentStack(chosen); | |
| const hooks = getHookSummary(chosen); | |
| const domRoots = collectHostNodesUnderFiber(chosen); | |
| const domRootsHtml = domRoots | |
| .map((n) => { | |
| try { | |
| if (n.nodeType === 1) return truncate(n.outerHTML || "", Math.floor(CFG.maxHtmlChars / 2)); | |
| if (n.nodeType === 3) return truncate(n.textContent || "", 1000); | |
| return `[Node ${n.nodeName}]`; | |
| } catch { | |
| return "[Unserializable node]"; | |
| } | |
| }) | |
| .filter(Boolean); | |
| ctx.react = { | |
| chosen: { | |
| name: chosenName, | |
| source: chosen._debugSource | |
| ? { fileName: chosen._debugSource.fileName, lineNumber: chosen._debugSource.lineNumber, columnNumber: chosen._debugSource.columnNumber } | |
| : null, | |
| props: chosenPropsStr, | |
| }, | |
| boundary: { | |
| nearest: nearestName, | |
| semantic: semanticName, | |
| used: preferSemantic ? "semantic" : "nearest", | |
| }, | |
| stack, | |
| hooks: safeStringify(hooks, CFG.maxPropsChars), | |
| componentDomRoots: domRootsHtml, | |
| }; | |
| if (!ctx.react.chosen.source) { | |
| ctx.notes.push("No React debug source location found (common in production builds)."); | |
| } | |
| if (nearestName && semanticName && nearestName !== semanticName) { | |
| ctx.notes.push(`Boundary note: nearest="${nearestName}", semantic="${semanticName}", using="${ctx.react.boundary.used}".`); | |
| } | |
| if (opts.includeFiberTree === true) { | |
| const fiberTree = buildFiberTree(chosen); | |
| ctx.react.fiberTree = safeStringify(fiberTree, CFG.maxPropsChars); | |
| } | |
| if (opts.deepCapture === true) { | |
| try { | |
| ctx.deep = deepCaptureUnderFiber(chosen); | |
| } catch (e) { | |
| ctx.notes.push(`Deep capture failed: ${String(e)}`); | |
| } | |
| } | |
| return ctx; | |
| } | |
| function formatForLLM(allCtx) { | |
| // allCtx: either {mode:"single", ctx} or {mode:"multi", ctxs:[...]} | |
| const lines = []; | |
| const page = allCtx?.page; | |
| if (page) { | |
| lines.push(`PAGE`); | |
| lines.push(`- Title: ${page.title}`); | |
| lines.push(`- URL: ${page.url}`); | |
| lines.push(``); | |
| } | |
| const ctxs = allCtx.mode === "multi" ? allCtx.ctxs : [allCtx.ctx]; | |
| ctxs.forEach((ctx, idx) => { | |
| lines.push(`SELECTION ${idx + 1}`); | |
| if (ctx.selection) { | |
| lines.push(`- CSS path: ${ctx.selection.cssPath || "(n/a)"}`); | |
| lines.push(`- tag: <${ctx.selection.tag}>`); | |
| if (ctx.selection.id) lines.push(`- id: ${ctx.selection.id}`); | |
| if (ctx.selection.classList?.length) lines.push(`- classes: ${ctx.selection.classList.join(" ")}`); | |
| if (ctx.selection.role) lines.push(`- role: ${ctx.selection.role}`); | |
| if (ctx.selection.dataTestId) lines.push(`- data-testid: ${ctx.selection.dataTestId}`); | |
| if (ctx.selection.ariaLabel) lines.push(`- aria-label: ${ctx.selection.ariaLabel}`); | |
| if (ctx.selection.ariaHaspopup) lines.push(`- aria-haspopup: ${ctx.selection.ariaHaspopup}`); | |
| if (ctx.selection.ariaExpanded) lines.push(`- aria-expanded: ${ctx.selection.ariaExpanded}`); | |
| if (ctx.selection.dataState) lines.push(`- data-state: ${ctx.selection.dataState}`); | |
| if (ctx.selection.textPreview) lines.push(`- text preview: ${ctx.selection.textPreview}`); | |
| lines.push(``); | |
| lines.push(`HTML (selected node)`); | |
| lines.push(ctx.selection.outerHTML || "(empty)"); | |
| lines.push(``); | |
| } | |
| if (ctx.react) { | |
| lines.push(`REACT CONTEXT`); | |
| lines.push(`- Boundary: nearest="${ctx.react.boundary.nearest}" semantic="${ctx.react.boundary.semantic}" using="${ctx.react.boundary.used}"`); | |
| lines.push(`- Chosen component: ${ctx.react.chosen.name}`); | |
| if (ctx.react.chosen.source) { | |
| const s = ctx.react.chosen.source; | |
| lines.push(`- Source: ${s.fileName}:${s.lineNumber}:${s.columnNumber}`); | |
| } else { | |
| lines.push(`- Source: (not available)`); | |
| } | |
| lines.push(``); | |
| lines.push(`Component stack (nearest-first)`); | |
| for (const item of ctx.react.stack || []) { | |
| if (item.source?.fileName) { | |
| lines.push(`- ${item.name} @ ${item.source.fileName}:${item.source.lineNumber}:${item.source.columnNumber}`); | |
| } else { | |
| lines.push(`- ${item.name}`); | |
| } | |
| } | |
| lines.push(``); | |
| lines.push(`Props (sanitized)`); | |
| lines.push(ctx.react.chosen.props || "(none)"); | |
| lines.push(``); | |
| lines.push(`Hook state summary (best-effort)`); | |
| lines.push(ctx.react.hooks || "(none)"); | |
| lines.push(``); | |
| if (ctx.react.fiberTree) { | |
| lines.push(`Fiber subtree (best-effort)`); | |
| lines.push(ctx.react.fiberTree); | |
| lines.push(``); | |
| } | |
| if (ctx.react.componentDomRoots?.length) { | |
| lines.push(`Component DOM root snippet(s) (best-effort)`); | |
| ctx.react.componentDomRoots.forEach((h, i) => { | |
| lines.push(`--- root ${i + 1} ---`); | |
| lines.push(h); | |
| }); | |
| lines.push(``); | |
| } | |
| } | |
| if (ctx.deep) { | |
| lines.push(`DEEP CAPTURE (nested components / dropdowns)`); | |
| if (ctx.deep.notableComponents?.length) { | |
| lines.push(`Notable component instances (heuristic)`); | |
| ctx.deep.notableComponents.forEach((c, i) => { | |
| lines.push(`- ${i + 1}. ${c.name}`); | |
| lines.push(` propsPicked: ${c.propsPicked}`); | |
| }); | |
| lines.push(``); | |
| } | |
| if (ctx.deep.interactive?.length) { | |
| lines.push(`Interactive host nodes (heuristic)`); | |
| ctx.deep.interactive.forEach((n, i) => { | |
| lines.push(`- ${i + 1}. <${n.tag || "?"}> ${n.cssPath || "(no path)"}`); | |
| if (n.role) lines.push(` role=${n.role}`); | |
| if (n.ariaLabel) lines.push(` aria-label=${n.ariaLabel}`); | |
| if (n.ariaHaspopup) lines.push(` aria-haspopup=${n.ariaHaspopup}`); | |
| if (n.ariaExpanded) lines.push(` aria-expanded=${n.ariaExpanded}`); | |
| if (n.dataState) lines.push(` data-state=${n.dataState}`); | |
| if (n.textPreview) lines.push(` text="${n.textPreview}"`); | |
| if (n.reactHandlers?.length) lines.push(` reactHandlers=${n.reactHandlers.join(", ")}`); | |
| if (n.componentStack?.length) lines.push(` componentStack=${n.componentStack.join(" > ")}`); | |
| if (n.html) { | |
| lines.push(` html:`); | |
| lines.push(` ${String(n.html).replace(/\n/g, "\n ")}`); | |
| } | |
| }); | |
| lines.push(``); | |
| } | |
| } | |
| if (ctx.notes?.length) { | |
| lines.push(`NOTES`); | |
| ctx.notes.forEach((n) => lines.push(`- ${n}`)); | |
| lines.push(``); | |
| } | |
| }); | |
| lines.push(`REQUEST TO AGENT`); | |
| lines.push(`- Recreate this UI and behavior in React.`); | |
| lines.push(`- Pay special attention to interactive elements (dropdowns/menus/popovers): use the "DEEP CAPTURE" section to infer triggers, open/closed state, and event handlers.`); | |
| lines.push(`- If the chosen component is still a layout primitive, use the boundary info and stack to propose a better component split (e.g., OrderBook -> Side -> Row -> SpreadRow).`); | |
| lines.push(``); | |
| return lines.join("\n"); | |
| } | |
| async function copyText(text) { | |
| try { | |
| if (typeof GM_setClipboard === "function") { | |
| GM_setClipboard(text, { type: "text", mimetype: "text/plain" }); | |
| return true; | |
| } | |
| await navigator.clipboard.writeText(text); | |
| return true; | |
| } catch (e) { | |
| console.warn("[RCP] Copy failed:", e); | |
| return false; | |
| } | |
| } | |
| function setHudOpen(open) { | |
| hud.classList.toggle("rcp__hud--open", open); | |
| } | |
| function setEnabled(next) { | |
| enabled = next; | |
| modePill.textContent = enabled ? "ON" : "OFF"; | |
| modePill.style.background = enabled ? "rgba(34,197,94,.85)" : "rgba(255,255,255,.12)"; | |
| highlight.style.display = enabled ? "block" : "none"; | |
| tooltip.style.display = enabled ? "block" : "none"; | |
| setHudOpen(true); | |
| metaEl.textContent = enabled | |
| ? "Picker enabled. Hover to inspect, click to select." | |
| : "Picker disabled. Toggle with Cmd/Ctrl+C."; | |
| } | |
| function renderSelectionList() { | |
| if (!selections.length) { | |
| listEl.style.display = "none"; | |
| listEl.innerHTML = ""; | |
| return; | |
| } | |
| listEl.style.display = "block"; | |
| listEl.innerHTML = selections | |
| .map((s, i) => { | |
| const nm = s.ctx?.react?.chosen?.name || "(no react)"; | |
| const path = s.ctx?.selection?.cssPath || "(no path)"; | |
| return `<div class="rcp__item"><strong>${i + 1}.</strong> ${nm}<div class="rcp__kbd">${escapeHtml(path)}</div></div>`; | |
| }) | |
| .join(""); | |
| } | |
| function escapeHtml(str) { | |
| return String(str) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function clearSelection() { | |
| selections = []; | |
| metaEl.textContent = enabled | |
| ? "Cleared. Hover to inspect, click to select." | |
| : "Cleared. Toggle with Cmd/Ctrl+C."; | |
| renderSelectionList(); | |
| } | |
| function currentOpts() { | |
| return { | |
| preferSemantic: !!preferSemanticEl.checked, | |
| deepCapture: !!deepCaptureEl.checked, | |
| includeFiberTree: !!includeFiberTreeEl.checked, | |
| }; | |
| } | |
| function addSelection(el, replace = false) { | |
| const opts = currentOpts(); | |
| const ctx = extractContextForElement(el, opts); | |
| if (replace) selections = [{ el, ctx }]; | |
| else selections.push({ el, ctx }); | |
| // keep selections bounded | |
| if (selections.length > 8) selections = selections.slice(selections.length - 8); | |
| const chosen = ctx?.react?.chosen?.name || "(no react)"; | |
| metaEl.textContent = `Selected (${selections.length}): ${chosen}`; | |
| setHudOpen(true); | |
| renderSelectionList(); | |
| } | |
| function getActiveCtxBundle() { | |
| if (selections.length === 0) return null; | |
| const page = selections[0]?.ctx?.page || { title: document.title, url: location.href }; | |
| if (selections.length === 1) return { mode: "single", page, ctx: selections[0].ctx }; | |
| return { mode: "multi", page, ctxs: selections.map((s) => s.ctx) }; | |
| } | |
| function getLastSelectionFiber() { | |
| const last = selections[selections.length - 1]; | |
| if (!last?.el) return null; | |
| return getReactFiberFromDomNode(last.el); | |
| } | |
| // ---------------------------- | |
| // Event handlers | |
| // ---------------------------- | |
| function updateHighlightAndTooltip(el, x, y) { | |
| if (!enabled) return; | |
| if (!el || el === document.documentElement || el === document.body) { | |
| highlight.style.display = "none"; | |
| tooltip.style.display = "none"; | |
| return; | |
| } | |
| const rect = el.getBoundingClientRect(); | |
| highlight.style.display = "block"; | |
| highlight.style.left = rect.left + "px"; | |
| highlight.style.top = rect.top + "px"; | |
| highlight.style.width = rect.width + "px"; | |
| highlight.style.height = rect.height + "px"; | |
| const fiber = getReactFiberFromDomNode(el); | |
| const nearest = fiber ? getNearestComponentFiber(fiber) : null; | |
| const semantic = fiber ? getNearestSemanticComponentFiber(fiber) : null; | |
| const nearestName = nearest ? (getComponentNameFromType(nearest.type) || "(Unknown)") : "(no React fiber)"; | |
| const semanticName = semantic ? (getComponentNameFromType(semantic.type) || "(Unknown)") : null; | |
| tooltip.innerHTML = ` | |
| <div><strong>${preferSemanticEl.checked ? semanticName || nearestName : nearestName}</strong></div> | |
| <div class="rcp__mono">nearest: ${nearestName}${semanticName ? ` • semantic: ${semanticName}` : ""}</div> | |
| <div class="rcp__mono">${cssPath(el)}</div> | |
| `; | |
| const pad = 14; | |
| const tw = tooltip.offsetWidth || 220; | |
| const th = tooltip.offsetHeight || 40; | |
| const left = Math.min(window.innerWidth - tw - 8, x + pad); | |
| const top = Math.min(window.innerHeight - th - 8, y + pad); | |
| tooltip.style.left = Math.max(8, left) + "px"; | |
| tooltip.style.top = Math.max(8, top) + "px"; | |
| tooltip.style.display = "block"; | |
| } | |
| function getElementUnderPointer(ev) { | |
| const el = document.elementFromPoint(ev.clientX, ev.clientY); | |
| if (!el) return null; | |
| if (hud.contains(el)) return null; | |
| if (root.contains(el) && el !== hud && !hud.contains(el)) return null; | |
| return el; | |
| } | |
| document.addEventListener("mousemove", (ev) => { | |
| if (!enabled) return; | |
| const el = getElementUnderPointer(ev); | |
| hoveredEl = el; | |
| updateHighlightAndTooltip(el, ev.clientX, ev.clientY); | |
| }, true); | |
| document.addEventListener("click", (ev) => { | |
| if (!enabled) return; | |
| if (hud.contains(ev.target)) return; | |
| ev.preventDefault(); | |
| ev.stopPropagation(); | |
| const el = getElementUnderPointer(ev) || hoveredEl; | |
| if (!el) return; | |
| const multi = !!multiSelectEl.checked; | |
| const add = multi && ev.shiftKey; | |
| addSelection(el, !add); | |
| }, true); | |
| document.addEventListener("keydown", async (ev) => { | |
| const metaOrCtrl = ev.metaKey || ev.ctrlKey; | |
| // Toggle: Cmd/Ctrl + C | |
| const isToggle = | |
| ev.code === CFG.toggleHotkey.code && | |
| metaOrCtrl === true && | |
| ev.shiftKey === CFG.toggleHotkey.shiftKey; | |
| if (isToggle) { | |
| // Keep normal copy in inputs or when selecting text | |
| if (isEditableTarget(ev.target) || hasTextSelection()) return; | |
| ev.preventDefault(); | |
| setEnabled(!enabled); | |
| return; | |
| } | |
| // Esc | |
| if (ev.code === "Escape") { | |
| if (enabled) { | |
| if (selections.length) clearSelection(); | |
| else setEnabled(false); | |
| ev.preventDefault(); | |
| } else { | |
| setHudOpen(false); | |
| } | |
| return; | |
| } | |
| // Copy: Cmd/Ctrl + Shift + C | |
| const isCopy = | |
| ev.code === CFG.copyHotkey.code && | |
| metaOrCtrl === true && | |
| ev.shiftKey === CFG.copyHotkey.shiftKey; | |
| if (isCopy && enabled) { | |
| if (isEditableTarget(ev.target) || hasTextSelection()) return; | |
| ev.preventDefault(); | |
| // If nothing selected, capture hovered element | |
| if (!selections.length && hoveredEl) addSelection(hoveredEl, true); | |
| const bundle = getActiveCtxBundle(); | |
| if (!bundle) return; | |
| const payload = formatForLLM(bundle); | |
| const ok = await copyText(payload); | |
| metaEl.textContent = ok ? "Copied context to clipboard." : "Copy failed (see console)."; | |
| setHudOpen(true); | |
| } | |
| }, true); | |
| copyBtn.addEventListener("click", async () => { | |
| const bundle = getActiveCtxBundle(); | |
| if (!bundle) { | |
| metaEl.textContent = "Nothing selected."; | |
| return; | |
| } | |
| const ok = await copyText(formatForLLM(bundle)); | |
| metaEl.textContent = ok ? "Copied context to clipboard." : "Copy failed (see console)."; | |
| }); | |
| copyHtmlBtn.addEventListener("click", async () => { | |
| if (!selections.length) { | |
| metaEl.textContent = "Nothing selected."; | |
| return; | |
| } | |
| const html = selections.map((s, i) => `<!-- selection ${i + 1} -->\n${s.ctx?.selection?.outerHTML || ""}`).join("\n\n"); | |
| const ok = await copyText(html); | |
| metaEl.textContent = ok ? "Copied HTML to clipboard." : "Copy failed (see console)."; | |
| }); | |
| copyJsonBtn.addEventListener("click", async () => { | |
| const bundle = getActiveCtxBundle(); | |
| if (!bundle) { | |
| metaEl.textContent = "Nothing selected."; | |
| return; | |
| } | |
| const ok = await copyText(safeStringify(bundle, 200000)); | |
| metaEl.textContent = ok ? "Copied JSON to clipboard." : "Copy failed (see console)."; | |
| }); | |
| selectParentBtn.addEventListener("click", () => { | |
| if (!selections.length) { | |
| metaEl.textContent = "Nothing selected."; | |
| return; | |
| } | |
| const last = selections[selections.length - 1]; | |
| const el = last?.el; | |
| if (!el) return; | |
| const fiber = getReactFiberFromDomNode(el); | |
| if (!fiber) { | |
| metaEl.textContent = "No React fiber for last selection."; | |
| return; | |
| } | |
| const opts = currentOpts(); | |
| const nearest = getNearestComponentFiber(fiber); | |
| const semantic = getNearestSemanticComponentFiber(fiber); | |
| // Move selection upward: if currently used semantic, go one semantic parent up; otherwise go to semantic. | |
| const preferSemantic = !!preferSemanticEl.checked; | |
| const base = preferSemantic ? semantic : nearest; | |
| if (!base) { | |
| metaEl.textContent = "Could not find component fiber."; | |
| return; | |
| } | |
| // Find next semantic parent above current semantic (skipping primitives/minified) | |
| let f = base.return, guard = 0; | |
| let next = null; | |
| while (f && guard++ < 500) { | |
| if (isLikelyComponentFiber(f)) { | |
| const name = getComponentNameFromType(f.type) || "(Unknown)"; | |
| if (!isPrimitiveName(name)) { next = f; break; } | |
| } | |
| f = f.return; | |
| } | |
| if (!next) { | |
| metaEl.textContent = "No higher semantic parent found."; | |
| return; | |
| } | |
| // Pick a DOM node under that parent as the “anchor” for selection context: | |
| // Use first host node we can find under next fiber. | |
| const roots = collectHostNodesUnderFiber(next); | |
| const anchor = roots?.[0] || el; | |
| addSelection(anchor, true); | |
| metaEl.textContent = `Selected parent semantic: ${getComponentNameFromType(next.type) || "(Unknown)"}`; | |
| }); | |
| clearBtn.addEventListener("click", () => clearSelection()); | |
| // Keep selection list updated when toggles change (deep capture affects ctx) | |
| const onOptsChange = () => { | |
| if (!selections.length) return; | |
| // Recompute ctx for existing selection elements with new opts | |
| const opts = currentOpts(); | |
| selections = selections.map((s) => ({ el: s.el, ctx: extractContextForElement(s.el, opts) })); | |
| renderSelectionList(); | |
| metaEl.textContent = `Updated capture options for ${selections.length} selection(s).`; | |
| }; | |
| preferSemanticEl.addEventListener("change", onOptsChange); | |
| deepCaptureEl.addEventListener("change", onOptsChange); | |
| includeFiberTreeEl.addEventListener("change", onOptsChange); | |
| // Initial state | |
| setHudOpen(true); | |
| setEnabled(false); | |
| metaEl.textContent = "Ready. Toggle with Cmd/Ctrl+C."; | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment