// ==UserScript== // @name DOM Debug Collector v3 // @namespace https://weblio.no/ // @version 3.0.0 // @description Alt-click elements to collect. Ctrl+Shift+D / F / G = dump 1 / 5 / 12 parent levels of computed styles + matched CSS rules to clipboard. Built for iterating on layout/CSS with an AI assistant - paste the dump directly into chat. // @author weblio.no // @homepage https://weblio.no/ // @license MIT // @match *://*/* // @match file:///* // @grant GM_setClipboard // @grant GM_registerMenuCommand // @run-at document-end // ==/UserScript== /* * DOM Debug Collector v3 * Copyright (c) 2026 weblio.no * * Released free under the MIT License (see below). Free to use, modify and * share. A small favour in return: keep this header so others know where it * came from - https://weblio.no * * MIT License * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ (function() { 'use strict'; // ═══════════════════════════════════════════════════════════════════ // CONFIG // ═══════════════════════════════════════════════════════════════════ // If Ctrl+Shift+D/F/G conflict with browser shortcuts on your setup, // flip MODIFIER to 'super' - on Linux that's the Windows/Meta key, on // Mac it's Cmd. Note: some desktop environments (GNOME, KDE) intercept // Super combos before the browser sees them - if that happens, keep // MODIFIER='ctrlShift'. const MODIFIER = 'ctrlShift'; // 'ctrlShift' | 'super' | 'alt' const MAX_OUTER_HTML = 3000; const BADGE_ID = '__dom_debug_badge__'; const HIGHLIGHT_CLASS = '__dom_debug_picked__'; // Depth presets for the three shortcuts. Ctrl+Shift+D = current element // only; Ctrl+Shift+F = medium trace; Ctrl+Shift+G = full-depth trace. const DEPTH = { D: 1, // just the selected element(s) F: 5, // walk 5 parents up - typical "why is this wrong" debugging G: 12, // walk all the way up - for weird cascade / inherited issues }; // ═══════════════════════════════════════════════════════════════════ // STATE // ═══════════════════════════════════════════════════════════════════ const collected = []; // ═══════════════════════════════════════════════════════════════════ // UI // ═══════════════════════════════════════════════════════════════════ const style = document.createElement('style'); style.textContent = ` #${BADGE_ID} { position: fixed; bottom: 14px; right: 14px; background: rgba(15,23,42,0.92); color: #facc15; border: 1px solid #facc15; padding: 6px 10px 6px 12px; border-radius: 999px; font: 600 12px/1.2 -apple-system, system-ui, sans-serif; letter-spacing: 0.04em; z-index: 2147483647; box-shadow: 0 4px 16px rgba(0,0,0,0.5); user-select: none; transition: transform 0.15s ease, opacity 0.15s ease, color 0.15s, border-color 0.15s; opacity: 0.92; max-width: 340px; display: inline-flex; align-items: center; gap: 6px; } #${BADGE_ID}.pulse { transform: scale(1.06); opacity: 1; } #${BADGE_ID} .dd-body { pointer-events: none; } #${BADGE_ID} .dd-sub { font-weight: 400; font-size: 10px; opacity: 0.75; margin-left: 4px; display: block; } #${BADGE_ID} .dd-x { pointer-events: auto; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; color: #facc15; opacity: 0.65; font-size: 13px; font-weight: 700; border: 1px solid transparent; background: transparent; margin-left: 2px; padding: 0; font-family: inherit; line-height: 1; transition: opacity 0.15s, background 0.15s, border-color 0.15s; } #${BADGE_ID} .dd-x:hover { opacity: 1; background: rgba(250, 204, 21, 0.15); border-color: #facc15; } /* Highlight visualization. Uses: - outline (outside the border box) - primary "picked" indicator for normal elements. - inset box-shadow - renders *inside* the border box, so it's still visible when a parent has overflow:hidden (which clips the outline). Common on inside
. */ .${HIGHLIGHT_CLASS} { outline: 2px solid #facc15 !important; outline-offset: 2px !important; box-shadow: inset 0 0 0 2px #facc15, 0 0 0 6px rgba(250,204,21,0.22) !important; } #${BADGE_ID}.toast { color: #86efac; border-color: #86efac; } #${BADGE_ID}.toast-warn { color: #fca5a5; border-color: #fca5a5; } `; document.documentElement.appendChild(style); const badge = document.createElement('div'); badge.id = BADGE_ID; renderBadge(); document.documentElement.appendChild(badge); function renderBadge(override) { if (override && override.text) { badge.innerHTML = `${override.text}` + xButtonHTML(); wireXButton(); badge.classList.remove('toast', 'toast-warn'); badge.classList.add(override.warn ? 'toast-warn' : 'toast'); badge.classList.add('pulse'); setTimeout(() => badge.classList.remove('pulse'), 180); setTimeout(() => { badge.classList.remove('toast', 'toast-warn'); renderBadge(); }, 1500); return; } const modLabel = MODIFIER === 'super' ? 'Super' : MODIFIER === 'alt' ? 'Alt+' : 'Ctrl+Shift'; badge.innerHTML = `📋 ${collected.length} collected` + `Alt-click · ${modLabel}+D/F/G` + xButtonHTML(); wireXButton(); badge.classList.add('pulse'); setTimeout(() => badge.classList.remove('pulse'), 180); } function xButtonHTML() { return ``; } function wireXButton() { const x = badge.querySelector('.dd-x'); if (!x) return; x.onclick = (e) => { e.preventDefault(); e.stopPropagation(); deactivate(); }; } /* Disables the script for the current page load: - removes all highlights - removes badge + injected stylesheet - detaches all listeners so Alt-click / Ctrl+Shift+D/F/G stop responding A reload re-injects everything fresh. To disable permanently, toggle the script off in the Tampermonkey dashboard. */ function deactivate() { clearAll(); document.removeEventListener('mousedown', handleAltMouse, true); document.removeEventListener('click', altClickSwallow, true); document.removeEventListener('keydown', keyHandler, true); if (badge && badge.parentNode) badge.parentNode.removeChild(badge); if (style && style.parentNode) style.parentNode.removeChild(style); console.log('[DOM Debug Collector] deactivated for this page. Reload to re-enable.'); } // ═══════════════════════════════════════════════════════════════════ // CLICK - Alt-click collect, Alt+Shift-click clear. // // Firefox intercepts click-on- for its native details toggle, // often *before* our click handler runs, which makes Alt-clicking a // element feel unresponsive (the details expand/collapse // instead of being collected). We hook `mousedown` in capture phase for // the Alt-path specifically so the native toggle can be pre-empted. // ═══════════════════════════════════════════════════════════════════ function handleAltMouse(e) { if (!e.altKey) return; const el = e.target; if (!el || !(el instanceof Element)) return; if (el.id === BADGE_ID || el.closest('#' + BADGE_ID)) return; e.preventDefault(); e.stopPropagation(); // Alt+Shift = clear everything if (e.shiftKey) { clearAll(); renderBadge({ text: `🗑 Cleared` }); return; } const idx = collected.indexOf(el); if (idx >= 0) { collected.splice(idx, 1); el.classList.remove(HIGHLIGHT_CLASS); } else { collected.push(el); el.classList.add(HIGHLIGHT_CLASS); } renderBadge(); } // Fire on mousedown (capture) to beat the native toggle, // then swallow the subsequent click so the details doesn't flap open/closed. // NOTE: both listeners are NAMED functions, not inline arrows. deactivate() // has to pass the identical reference to removeEventListener, and an inline // arrow can never be detached -- that was the v2 bug where the x button threw // ReferenceError and left the shortcuts live. function altClickSwallow(e) { if (e.altKey) { e.preventDefault(); e.stopPropagation(); } } document.addEventListener('mousedown', handleAltMouse, true); document.addEventListener('click', altClickSwallow, true); // ═══════════════════════════════════════════════════════════════════ // KEYBOARD - D/F/G for shallow / medium / deep dumps // ═══════════════════════════════════════════════════════════════════ function matchesModifier(e) { if (MODIFIER === 'super') return e.metaKey && !e.ctrlKey && !e.altKey; if (MODIFIER === 'alt') return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey; // default: ctrlShift return e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey; } // Named for the same reason as altClickSwallow: deactivate() must be able to // detach this exact reference. function keyHandler(e) { if (!matchesModifier(e)) return; const key = e.key.toUpperCase(); if (key !== 'D' && key !== 'F' && key !== 'G') return; e.preventDefault(); e.stopPropagation(); if (collected.length === 0) { renderBadge({ text: `⚠ Nothing collected`, warn: true }); return; } const depth = DEPTH[key]; const dump = buildDump(collected, depth); copyToClipboard(dump); renderBadge({ text: `✓ Copied ${collected.length} × ${depth} level${depth === 1 ? '' : 's'}`, }); } document.addEventListener('keydown', keyHandler, true); // ═══════════════════════════════════════════════════════════════════ // DUMP // ═══════════════════════════════════════════════════════════════════ function buildDump(elements, depth) { const header = `# DOM Debug Dump - ${elements.length} element${elements.length === 1 ? '' : 's'}, ` + `${depth} level${depth === 1 ? '' : 's'} per element\n` + `# URL: ${location.href}\n` + `# Time: ${new Date().toISOString()}\n`; const sections = elements.map((el, i) => dumpElementWithAncestors(el, i + 1, elements.length, depth)); return header + '\n' + sections.join('\n\n═══════════════════════════════════════\n\n'); } function dumpElementWithAncestors(el, n, total, depth) { const lines = []; lines.push(`## [${n}/${total}] ${tagSummary(el)}`); lines.push(''); // Walk up `depth` levels, dumping each let node = el; let level = 0; while (node && node.nodeType === 1 && node !== document.documentElement && level < depth) { if (level > 0) lines.push('\n### ─── Parent ' + level + ' ───\n'); lines.push(...dumpOneNode(node, level === 0)); node = node.parentElement; level++; } return lines.join('\n'); } function dumpOneNode(node, isSelected) { const lines = []; const heading = isSelected ? 'Selected element' : 'Ancestor'; lines.push(`**${heading}:** \`${tagSummary(node)}\``); lines.push(''); // Outer HTML (truncated) const outer = node.outerHTML; lines.push('Outer HTML:'); lines.push('```html'); lines.push(outer.length > MAX_OUTER_HTML ? outer.slice(0, MAX_OUTER_HTML) + '\n... (truncated)' : outer); lines.push('```'); // Locators lines.push('CSS: `' + cssPath(node) + '`'); lines.push('XPath: `' + xPath(node) + '`'); // Computed styles const comp = getComputedStyle(node); const styleLines = []; const props = [ 'display', 'position', 'width', 'height', 'max-width', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-radius', 'background-color', 'color', 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'flex', 'flex-direction', 'flex-wrap', 'align-items', 'justify-content', 'gap', 'row-gap', 'column-gap', 'grid-template-columns', 'grid-template-rows', 'z-index', 'opacity', 'overflow', ]; for (const p of props) { const v = comp.getPropertyValue(p); if (!v || v === '' || v === 'none' || v === 'normal' || v === 'auto' || v === '0px' || v === 'rgba(0, 0, 0, 0)' || v === 'start' || v === 'visible' || v === 'baseline' || v === 'stretch') continue; styleLines.push(` ${p}: ${v};`); } if (styleLines.length) { lines.push('Computed:'); lines.push('```'); lines.push(...styleLines); lines.push('```'); } // Matched CSS rules const matched = collectMatchedRules(node); if (matched.length) { lines.push('Matched rules:'); lines.push('```css'); for (const m of matched) { lines.push(`/* from: ${m.source} */`); lines.push(`${m.selector} {`); for (const [prop, val, imp] of m.decls) { lines.push(` ${prop}: ${val}${imp ? ' !important' : ''};`); } lines.push('}'); } lines.push('```'); } // Inline style if (node.style && node.style.cssText) { lines.push('Inline: `' + node.style.cssText + '`'); } return lines; } function tagSummary(el) { if (!el || !el.tagName) return '(none)'; const tag = el.tagName.toLowerCase(); const id = el.id ? `#${el.id}` : ''; // Build class selector only if there are actual non-empty class names // (filter out HIGHLIGHT_CLASS and any empty strings from whitespace splits) let cls = ''; if (el.className && typeof el.className === 'string') { const names = el.className.trim().split(/\s+/).filter(c => c && c !== HIGHLIGHT_CLASS); if (names.length) cls = '.' + names.join('.'); } return `${tag}${id}${cls}`; } function cssPath(el) { const parts = []; while (el && el.nodeType === 1 && el !== document.documentElement) { let sel = el.tagName.toLowerCase(); if (el.id) { parts.unshift(`${sel}#${el.id}`); break; } if (el.className && typeof el.className === 'string') { sel += '.' + el.className.trim().split(/\s+/).filter(c => c !== HIGHLIGHT_CLASS).join('.'); } const parent = el.parentElement; if (parent) { const sameTag = [...parent.children].filter(c => c.tagName === el.tagName); if (sameTag.length > 1) { const idx = [...parent.children].indexOf(el) + 1; sel += `:nth-child(${idx})`; } } parts.unshift(sel); el = parent; } return parts.join(' > '); } function xPath(el) { const parts = []; while (el && el.nodeType === 1) { let idx = 1; for (let sib = el.previousElementSibling; sib; sib = sib.previousElementSibling) { if (sib.tagName === el.tagName) idx++; } parts.unshift(`${el.tagName.toLowerCase()}[${idx}]`); el = el.parentElement; } return '/' + parts.join('/'); } function collectMatchedRules(el) { const out = []; try { const sheets = [...document.styleSheets]; for (const sheet of sheets) { let rules; try { rules = [...sheet.cssRules]; } catch (e) { continue; } for (const rule of rules) { if (!rule.selectorText) continue; const selectors = rule.selectorText.split(',').map(s => s.trim()); let matches = false; for (const s of selectors) { try { if (el.matches(s)) { matches = true; break; } } catch (e) {} } if (!matches) continue; const decls = []; for (let i = 0; i < rule.style.length; i++) { const prop = rule.style[i]; decls.push([prop, rule.style.getPropertyValue(prop), !!rule.style.getPropertyPriority(prop)]); } out.push({ source: sheet.href ? sheet.href.split('/').pop().split('?')[0] : 'inline