diff --git a/extension/STORE_LISTING.md b/extension/STORE_LISTING.md new file mode 100644 index 000000000..d69d65122 --- /dev/null +++ b/extension/STORE_LISTING.md @@ -0,0 +1,63 @@ +# Chrome Web Store Listing + +## Name +Impeccable + +## Short description (132 chars max) +Detect AI slop and design anti-patterns in any web page. Open DevTools and see what needs fixing. + +## Detailed description + +Impeccable detects 24 common UI anti-patterns directly in your browser. Open DevTools on any page and overlays instantly highlight issues, from AI-generated design tells to accessibility and quality problems. + +WHAT IT DETECTS + +AI slop (design tells that scream "AI made this"): +- Side-tab accent borders +- Gradient text on headings +- Purple/violet AI color palettes +- Nested cards, monotonous spacing +- Bounce/elastic easing +- Dark mode with glowing accents +- Overused fonts, flat type hierarchy + +Quality issues (general design and accessibility): +- Low contrast text (WCAG AA) +- Cramped padding, tight line height +- Skipped heading levels +- Line length too long +- Tiny body text, justified text +- Layout property animations + +HOW IT WORKS + +1. Install the extension +2. Open DevTools on any page (Cmd+Opt+I / F12) +3. Overlays appear automatically, highlighting issues +4. Click the "Impeccable" panel tab for a structured list of all findings +5. Click any finding to jump to the element in the Elements panel + +FEATURES + +- Auto-scans when DevTools opens, no manual step needed +- Grouped findings: AI tells vs. quality issues +- Click-to-inspect: jump from a finding to the element +- Toggle overlays on/off from the panel or toolbar popup +- Per-rule settings: disable detections you don't care about +- Re-scans on navigation, including SPA route changes +- Works on any website +- Runs 100% locally, no data sent anywhere + +Open source at https://github.com/pbakaus/impeccable + +## Category +Developer Tools + +## Language +English + +## Privacy policy URL +https://impeccable.style/privacy + +## Single purpose description +Detects and highlights UI anti-patterns (AI-generated design tells and general quality issues) on any web page. diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js index 488072c17..1b8b4c768 100644 --- a/extension/background/service-worker.js +++ b/extension/background/service-worker.js @@ -13,7 +13,7 @@ const panelPorts = new Map(); function getState(tabId) { if (!tabState.has(tabId)) { - tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false }); + tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false, csInjected: false }); } return tabState.get(tabId); } @@ -35,17 +35,47 @@ function notifyPanels(tabId, message) { } } -async function getDisabledRules() { - const result = await chrome.storage.sync.get({ disabledRules: [] }); - return result.disabledRules; +async function getSettings() { + return chrome.storage.sync.get({ + disabledRules: [], + lineLengthMode: 'strict', // 'strict' = 80, 'lax' = 120 + spotlightBlur: true, // dim/blur the page on hover-highlight + autoScan: 'panel', // 'panel' = scan when Impeccable UI opens, 'devtools' = scan when DevTools opens + }); } async function buildScanConfig() { - const disabledRules = await getDisabledRules(); - return disabledRules.length ? { disabledRules } : null; + const { disabledRules, lineLengthMode, spotlightBlur } = await getSettings(); + const config = {}; + if (disabledRules.length) config.disabledRules = disabledRules; + config.lineLengthMax = lineLengthMode === 'lax' ? 120 : 80; + config.spotlightBlur = spotlightBlur; + return config; +} + +// Inject the content script on-demand. We removed the static content_scripts entry to +// minimize the always-on footprint; the script is only loaded when the user explicitly +// engages with the extension (DevTools panel/sidebar opened, popup scan, etc). +async function ensureContentScriptInjected(tabId) { + const state = getState(tabId); + if (state.csInjected) return true; + try { + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['content/content-script.js'], + injectImmediately: true, + }); + state.csInjected = true; + return true; + } catch (err) { + // Common cause: chrome:// pages, the web store, or other restricted URLs + return false; + } } async function sendScanToTab(tabId) { + const ok = await ensureContentScriptInjected(tabId); + if (!ok) return; const config = await buildScanConfig(); chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {}); } @@ -75,6 +105,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: true }); } + else if (msg.action === 'page-pointer-active' && tabId) { + notifyPanels(tabId, { action: 'page-pointer-active' }); + sendResponse({ ok: true }); + } + else if (msg.action === 'overlays-toggled' && tabId) { const state = getState(tabId); state.overlaysVisible = msg.visible; @@ -115,6 +150,23 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // Track which tabs have DevTools open (via the devtools.js lifecycle port) const devtoolsTabs = new Set(); +async function tearDownTab(tabId) { + devtoolsTabs.delete(tabId); + // Send the remove command and await it — this keeps the SW alive long enough + // to actually deliver the message (setTimeout doesn't survive SW termination in MV3). + try { + await chrome.tabs.sendMessage(tabId, { action: 'remove' }); + } catch { /* tab might be closed or content script gone */ } + const state = tabState.get(tabId); + if (state) { + state.findings = []; + state.injected = false; + state.csInjected = false; + } + updateBadge(tabId); + panelPorts.delete(tabId); +} + // Handle long-lived connections from DevTools pages and panels chrome.runtime.onConnect.addListener((port) => { // Lifecycle port from devtools.js -- tracks DevTools open/close @@ -124,19 +176,13 @@ chrome.runtime.onConnect.addListener((port) => { port.onMessage.addListener((msg) => { if (msg.action === 'scan') sendScanToTab(tabId); + // 'ping' is just a keepalive; no action needed }); port.onDisconnect.addListener(() => { - devtoolsTabs.delete(tabId); - // DevTools closed -- remove overlays and clear state - chrome.tabs.sendMessage(tabId, { action: 'remove' }).catch(() => {}); - const state = tabState.get(tabId); - if (state) { - state.findings = []; - state.injected = false; - } - updateBadge(tabId); - panelPorts.delete(tabId); + // Tear down immediately — defer with setTimeout doesn't work reliably in MV3 + // because the SW can be terminated before the timer fires. + tearDownTab(tabId); }); } @@ -160,6 +206,10 @@ chrome.runtime.onConnect.addListener((port) => { sendScanToTab(tabId); } else if (msg.action === 'toggle-overlays') { chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {}); + } else if (msg.action === 'highlight') { + chrome.tabs.sendMessage(tabId, { action: 'highlight', selector: msg.selector }).catch(() => {}); + } else if (msg.action === 'unhighlight') { + chrome.tabs.sendMessage(tabId, { action: 'unhighlight' }).catch(() => {}); } }); @@ -168,19 +218,40 @@ chrome.runtime.onConnect.addListener((port) => { if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId); }); } + + // Sidebar pane port (Elements panel sidebar) -- receives findings updates. + // Connecting the sidebar is a strong signal of "user engaged with Impeccable" + // so we trigger a scan if no findings exist yet (matches the panel port behavior). + if (port.name.startsWith('impeccable-sidebar-')) { + const tabId = parseInt(port.name.replace('impeccable-sidebar-', ''), 10); + if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set()); + panelPorts.get(tabId).add(port); + + const state = getState(tabId); + port.postMessage({ action: 'state', ...state }); + if (!state.findings.length) sendScanToTab(tabId); + + port.onDisconnect.addListener(() => { + panelPorts.get(tabId)?.delete(port); + if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId); + }); + } }); -// Re-scan on navigation (only if DevTools is open for that tab) +// Re-scan on navigation (only if DevTools is open AND user was actively scanning) chrome.webNavigation?.onCompleted?.addListener((details) => { if (details.frameId !== 0) return; if (!devtoolsTabs.has(details.tabId)) return; const state = tabState.get(details.tabId); - if (state) { - state.findings = []; - state.injected = false; - updateBadge(details.tabId); - notifyPanels(details.tabId, { action: 'navigated' }); - // Re-inject and scan after a short delay for the page to settle + if (!state) return; + // Only re-scan if the user has actively engaged (had findings or injected previously) + const wasActive = state.injected || state.findings.length > 0; + state.findings = []; + state.injected = false; + state.csInjected = false; // page reload destroys the content script + updateBadge(details.tabId); + notifyPanels(details.tabId, { action: 'navigated' }); + if (wasActive) { setTimeout(() => sendScanToTab(details.tabId), 300); } }); diff --git a/extension/content/content-script.js b/extension/content/content-script.js index 4fb37b51b..1007e770a 100644 --- a/extension/content/content-script.js +++ b/extension/content/content-script.js @@ -4,95 +4,122 @@ * Bridges between the extension messaging system and the page-context detector. * The detector must run in page context (not isolated world) because it needs * access to getComputedStyle, document.styleSheets.cssRules, etc. + * + * Wrapped in an IIFE with an idempotency flag so re-injection (via + * chrome.scripting.executeScript) is a no-op and doesn't cause: + * - SyntaxError: Identifier 'foo' has already been declared + * - Duplicate event listeners accumulating over time */ +(function () { + if (window.__IMPECCABLE_CS_LOADED__) return; + window.__IMPECCABLE_CS_LOADED__ = true; -let injected = false; -let pendingScan = false; -let scanConfig = null; + let injected = false; + let pendingScan = false; + let scanConfig = null; -// Listen for commands from the service worker -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.action === 'scan') { - scanConfig = msg.config || null; - injectAndScan(); - sendResponse({ ok: true }); - } else if (msg.action === 'toggle-overlays') { - window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*'); - sendResponse({ ok: true }); - } else if (msg.action === 'remove') { - window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); - injected = false; - sendResponse({ ok: true }); - } - return true; -}); + // Listen for commands from the service worker + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.action === 'scan') { + scanConfig = msg.config || null; + injectAndScan(); + sendResponse({ ok: true }); + } else if (msg.action === 'toggle-overlays') { + window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*'); + sendResponse({ ok: true }); + } else if (msg.action === 'remove') { + window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*'); + injected = false; + sendResponse({ ok: true }); + } else if (msg.action === 'highlight') { + window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*'); + sendResponse({ ok: true }); + } else if (msg.action === 'unhighlight') { + window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*'); + sendResponse({ ok: true }); + } + return true; + }); -// Listen for results and state changes from the detector in page context -window.addEventListener('message', (e) => { - if (e.source !== window || !e.data) return; + // Listen for results and state changes from the detector in page context + window.addEventListener('message', (e) => { + if (e.source !== window || !e.data) return; - if (e.data.source === 'impeccable-results') { - chrome.runtime.sendMessage({ - action: 'findings', - findings: e.data.findings, - count: e.data.count, - }).catch(() => {}); - } + if (e.data.source === 'impeccable-results') { + chrome.runtime.sendMessage({ + action: 'findings', + findings: e.data.findings, + count: e.data.count, + }).catch(() => {}); + } - if (e.data.source === 'impeccable-overlays-toggled') { - chrome.runtime.sendMessage({ - action: 'overlays-toggled', - visible: e.data.visible, - }).catch(() => {}); - } + if (e.data.source === 'impeccable-overlays-toggled') { + chrome.runtime.sendMessage({ + action: 'overlays-toggled', + visible: e.data.visible, + }).catch(() => {}); + } - if (e.data.source === 'impeccable-ready') { - injected = true; - if (pendingScan) { - pendingScan = false; - sendScanCommand(); + if (e.data.source === 'impeccable-ready') { + injected = true; + if (pendingScan) { + pendingScan = false; + sendScanCommand(); + } + } + }); + + // Forward "page is active" signal to the extension when the cursor moves over the page. + // This is the reliable way to know the user has left the DevTools panel — the panel's + // own pointerleave/mouseleave events are unreliable on fast cursor movement. + let lastPageActive = 0; + document.addEventListener('pointermove', () => { + const now = Date.now(); + if (now - lastPageActive < 150) return; // throttle + lastPageActive = now; + chrome.runtime.sendMessage({ action: 'page-pointer-active' }).catch(() => {}); + }, { passive: true, capture: true }); + + // SPA navigation detection (pushState/replaceState don't fire events, but + // popstate and hashchange cover back/forward and hash navigation) + let lastUrl = location.href; + function onPossibleNavigation() { + if (location.href === lastUrl) return; + lastUrl = location.href; + if (injected) { + // Detector is still loaded in page context, just re-scan after DOM settles + setTimeout(sendScanCommand, 500); } } -}); + window.addEventListener('popstate', onPossibleNavigation); + window.addEventListener('hashchange', onPossibleNavigation); -// SPA navigation detection (pushState/replaceState don't fire events, but -// popstate and hashchange cover back/forward and hash navigation) -let lastUrl = location.href; -function onPossibleNavigation() { - if (location.href === lastUrl) return; - lastUrl = location.href; - if (injected) { - // Detector is still loaded in page context, just re-scan after DOM settles - setTimeout(sendScanCommand, 500); - } -} -window.addEventListener('popstate', onPossibleNavigation); -window.addEventListener('hashchange', onPossibleNavigation); - -function sendScanCommand() { - const msg = { source: 'impeccable-command', action: 'scan' }; - if (scanConfig) msg.config = scanConfig; - window.postMessage(msg, '*'); -} - -function injectAndScan() { - if (injected) { - sendScanCommand(); - return; + function sendScanCommand() { + const msg = { source: 'impeccable-command', action: 'scan' }; + if (scanConfig) msg.config = scanConfig; + window.postMessage(msg, '*'); } - // Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM) - document.documentElement.dataset.impeccableExtension = 'true'; + function injectAndScan() { + if (injected) { + sendScanCommand(); + return; + } - // Inject the detector script into page context - const script = document.createElement('script'); - script.src = chrome.runtime.getURL('detector/detect.js'); - pendingScan = true; - script.onload = () => script.remove(); - script.onerror = () => { - script.remove(); - // Fallback: use chrome.scripting.executeScript for strict CSP pages - chrome.runtime.sendMessage({ action: 'inject-fallback' }); - }; - (document.head || document.documentElement).appendChild(script); -} + // Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM) + document.documentElement.dataset.impeccableExtension = 'true'; + + // Inject the detector script into page context + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('detector/detect.js'); + script.dataset.impeccableExtension = 'true'; + pendingScan = true; + script.onload = () => script.remove(); + script.onerror = () => { + script.remove(); + // Fallback: use chrome.scripting.executeScript for strict CSP pages + chrome.runtime.sendMessage({ action: 'inject-fallback' }); + }; + (document.head || document.documentElement).appendChild(script); + } +})(); diff --git a/extension/devtools/devtools.js b/extension/devtools/devtools.js index bbd6bc75f..cebb52534 100644 --- a/extension/devtools/devtools.js +++ b/extension/devtools/devtools.js @@ -12,10 +12,39 @@ chrome.devtools.panels.create( 'devtools/panel.html' ); -// Connect a lifecycle port so the service worker knows when DevTools closes -const port = chrome.runtime.connect({ - name: `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`, +// Sidebar pane in the Elements panel: shows findings for the currently selected element +chrome.devtools.panels.elements.createSidebarPane('Impeccable', (sidebar) => { + sidebar.setPage('devtools/sidebar.html'); + sidebar.setHeight('200px'); }); -// Auto-scan when DevTools opens (regardless of which panel is active). -port.postMessage({ action: 'scan' }); +// Lifecycle port to the service worker. Auto-reconnects if the SW gets terminated +// (which can happen in MV3 after ~30s of inactivity, especially when the browser is unfocused). +const portName = `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`; +let lifecyclePort = null; +let firstConnect = true; +function connectLifecycle() { + lifecyclePort = chrome.runtime.connect({ name: portName }); + // On the very first connection, decide whether to auto-scan based on the user's setting. + // Default ('panel'): wait until the user opens the Impeccable panel or sidebar. + // Opt-in ('devtools'): scan immediately when DevTools opens. + if (firstConnect) { + firstConnect = false; + chrome.storage.sync.get({ autoScan: 'panel' }, (settings) => { + if (settings.autoScan === 'devtools') { + try { lifecyclePort?.postMessage({ action: 'scan' }); } catch {} + } + }); + } + lifecyclePort.onDisconnect.addListener(() => { + lifecyclePort = null; + // Reconnect on the next tick so the SW sees a fresh connection + setTimeout(connectLifecycle, 100); + }); +} +connectLifecycle(); + +// Heartbeat to keep the SW alive +setInterval(() => { + try { lifecyclePort?.postMessage({ action: 'ping' }); } catch {} +}, 20000); diff --git a/extension/devtools/panel.css b/extension/devtools/panel.css index aee800404..29d322110 100644 --- a/extension/devtools/panel.css +++ b/extension/devtools/panel.css @@ -113,12 +113,8 @@ h1 { color: var(--text); } -.tool-btn.active { - color: var(--accent); -} - -.tool-btn.active:hover { - color: var(--text); +.tool-btn.inactive { + opacity: 0.4; } /* Findings */ @@ -192,6 +188,50 @@ h1 { background: var(--bg-hover); } +.finding-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.finding-row .finding-selector { + flex: 1; + min-width: 0; +} + +.finding-copy { + display: none; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.finding-item:hover .finding-copy { + display: flex; +} + +.finding-copy:hover { + background: var(--bg); + color: var(--accent); +} + +.finding-copy.copied { + color: var(--accent); +} + +.tool-btn.copied { + color: var(--accent); +} + .finding-selector { font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace; font-size: 11px; @@ -222,13 +262,35 @@ h1 { display: block; } -/* Page-level findings */ -.page-level-tag { - font-size: 10px; - font-weight: 500; - color: var(--accent-dim); +/* Finding tags (page-level, hidden, etc.) */ +.finding-tag { + display: inline-block; + font-size: 9px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; + padding: 1px 5px; + border-radius: 3px; + margin-bottom: 2px; +} + +.tag-page { + color: var(--accent-dim); + background: transparent; +} + +.tag-hidden { + color: var(--text-dim); + background: var(--bg-hover); +} + +.finding-item.is-hidden { + opacity: 0.55; + cursor: default; +} + +.finding-item.is-hidden:hover { + background: transparent; } /* Empty state */ @@ -315,10 +377,103 @@ h1 { padding: 8px 8px 6px; } -#settings-list { +.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px 12px; + padding-bottom: 8px; +} + +#settings-list .settings-header { + padding: 8px 8px 4px; +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px 8px; + gap: 12px; +} + +.setting-label { + font-size: 11px; + color: var(--text); +} + +.setting-segmented { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.setting-segmented button { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 11px; + padding: 3px 8px; + cursor: pointer; + font-family: inherit; + border-right: 1px solid var(--border); +} + +.setting-segmented button:last-child { + border-right: none; +} + +.setting-segmented button:hover { + color: var(--text); +} + +.setting-segmented button.active { + background: var(--accent); + color: white; +} + +.setting-switch { + position: relative; + display: inline-block; + width: 28px; + height: 16px; + cursor: pointer; + flex-shrink: 0; +} + +.setting-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.setting-switch-track { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: var(--border); + border-radius: 8px; + transition: background 0.15s ease; +} + +.setting-switch-track::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: var(--bg); + border-radius: 50%; + transition: transform 0.15s ease; +} + +.setting-switch input:checked + .setting-switch-track { + background: var(--accent); +} + +.setting-switch input:checked + .setting-switch-track::before { + transform: translateX(12px); } .setting-rule { diff --git a/extension/devtools/panel.html b/extension/devtools/panel.html index ac6799bd5..ea936d504 100644 --- a/extension/devtools/panel.html +++ b/extension/devtools/panel.html @@ -12,10 +12,13 @@ 0
`; } +function formatFindingsForCopy(findings) { + if (!findings.length) return 'No anti-patterns detected.'; + const lines = ['Impeccable detected the following UI anti-patterns:', '']; + const groups = { slop: [], quality: [] }; + for (const item of findings) { + for (const f of item.findings) { + const cat = f.category || 'quality'; + groups[cat].push({ ...f, selector: item.selector, isPageLevel: item.isPageLevel }); + } + } + if (groups.slop.length) { + lines.push('## AI tells'); + for (const f of groups.slop) { + lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`); + } + lines.push(''); + } + if (groups.quality.length) { + lines.push('## Quality issues'); + for (const f of groups.quality) { + lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`); + } + lines.push(''); + } + lines.push('Please fix these issues.'); + return lines.join('\n'); +} + +function formatSingleFindingForCopy(item, finding) { + const where = item.isPageLevel ? '(page-level)' : `at \`${item.selector}\``; + return `Impeccable detected: ${finding.name} ${where}\nDetail: ${finding.detail}\n\n${finding.description}\n\nPlease fix this.`; +} + +async function copyToClipboard(text, btn) { + try { + await navigator.clipboard.writeText(text); + if (btn) { + const orig = btn.title; + btn.title = 'Copied!'; + btn.classList.add('copied'); + setTimeout(() => { + btn.title = orig; + btn.classList.remove('copied'); + }, 1200); + } + } catch (err) { + console.warn('Copy failed', err); + } +} + +btnCopyAll.addEventListener('click', () => { + copyToClipboard(formatFindingsForCopy(currentFindings), btnCopyAll); +}); + +// Delegated hover tracking on the findings container. +// Reliably handles cursor moving between items, into children, or out of the panel. +let currentHoverSelector = null; +function setHoveredItem(selector) { + if (selector === currentHoverSelector) return; + currentHoverSelector = selector; + if (selector) { + postToPort({ action: 'highlight', selector }); + } else { + postToPort({ action: 'unhighlight' }); + } +} + +container.addEventListener('pointermove', (e) => { + const item = e.target.closest('.finding-item'); + const selector = item && !item.classList.contains('is-hidden') ? item.dataset.selector || null : null; + setHoveredItem(selector); +}); + +// Slow-cursor fallbacks (these fire reliably for slow movements) +container.addEventListener('pointerleave', () => setHoveredItem(null)); +window.addEventListener('blur', () => setHoveredItem(null)); + + function renderFindings(findings) { + currentFindings = findings; if (!findings.length) { container.innerHTML = ''; container.appendChild(emptyState); @@ -145,6 +330,7 @@ function renderFindings(findings) { selector: item.selector, tagName: item.tagName, isPageLevel: item.isPageLevel, + isHidden: item.isHidden, detail: f.detail, }); } @@ -186,14 +372,30 @@ function renderFindings(findings) { for (const item of group.items) { const itemEl = document.createElement('div'); - itemEl.className = 'finding-item'; + itemEl.className = 'finding-item' + (item.isHidden ? ' is-hidden' : ''); + const tag = item.isPageLevel + ? 'page' + : item.isHidden ? 'hidden' : ''; itemEl.innerHTML = ` - ${item.isPageLevel ? 'page' : ''} - ${escapeHtml(item.selector)} + ${tag} +Last updated: March 24, 2026
+Last updated: April 6, 2026
Impeccable is an open-source collection of agent skills (text files) that run locally in your AI coding tool. The skills themselves collect no data, make no network requests, and have no analytics.
@@ -32,6 +32,17 @@When installed as a Claude Code plugin, Impeccable runs entirely within your local Claude Code session. No data is sent to Impeccable's servers. Anthropic's own privacy policy governs the Claude Code application itself.
+The Impeccable Chrome DevTools extension runs entirely in your browser. All anti-pattern detection happens locally on the page you are inspecting. No page content, URLs, or detection results are ever sent to any external server.
+The extension stores your rule preferences (which detections are enabled or disabled) using Chrome's built-in sync storage (chrome.storage.sync), which syncs settings across your Chrome instances via your Google account. No other data is stored or transmitted.
The extension requests the following permissions:
+The source code is hosted on GitHub. Interactions with the repository (issues, pull requests, stars) are governed by GitHub's privacy policy.
diff --git a/scripts/generate-promo-tile.js b/scripts/generate-promo-tile.js new file mode 100644 index 000000000..d35d9736a --- /dev/null +++ b/scripts/generate-promo-tile.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +/** + * Generates the Chrome Web Store small promo tile (440x280) from an SVG template. + * + * Run: node scripts/generate-promo-tile.js + */ + +import puppeteer from 'puppeteer'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const OUT = path.join(ROOT, 'extension/icons/promo-small.png'); + +// Brand colors +const BG = '#0e0d10'; +const BG_TOP = '#161318'; +const MAGENTA = '#cc1b89'; // approximates oklch(55% 0.25 350) +const TEXT = '#f5f3ef'; +const TEXT_DIM = '#7a7680'; + +const svg = ` + +`; + +const browser = await puppeteer.launch({ headless: true }); +const page = await browser.newPage(); +await page.setViewport({ width: 440, height: 280, deviceScaleFactor: 1 }); +await page.setContent(` + + + + ${svg} + +`); +await page.screenshot({ path: OUT, omitBackground: false }); +await browser.close(); + +console.log(`Generated ${path.relative(ROOT, OUT)}`); diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js index d243cab92..55bb1d3ae 100644 --- a/src/detect-antipatterns-browser.js +++ b/src/detect-antipatterns-browser.js @@ -52,6 +52,26 @@ const OVERUSED_FONTS = new Set([ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', ]); +// Brand-associated fonts: don't flag these as "overused" on the brand's own domains. +// Keys are font names, values are arrays of hostname suffixes where the font is allowed. +const GOOGLE_DOMAINS = [ + 'google.com', 'youtube.com', 'android.com', 'chromium.org', + 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', +]; +const BRAND_FONT_DOMAINS = { + 'roboto': GOOGLE_DOMAINS, + 'google sans': GOOGLE_DOMAINS, + 'product sans': GOOGLE_DOMAINS, +}; + +function isBrandFontOnOwnDomain(font) { + if (typeof location === 'undefined') return false; + const allowed = BRAND_FONT_DOMAINS[font]; + if (!allowed) return false; + const host = location.hostname.toLowerCase(); + return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); +} + const GENERIC_FONTS = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', @@ -808,11 +828,12 @@ function checkElementQualityDOM(el) { const rect = el.getBoundingClientRect(); // --- Line length too long --- - // Only flag if text is long enough to actually fill the line (>80 chars) - if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) { + // Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80) + const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80; + if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { const charsPerLine = rect.width / (fontSize * 0.5); - if (charsPerLine > 85) { - findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` }); + if (charsPerLine > lineMax + 5) { + findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` }); } } @@ -864,8 +885,12 @@ function checkElementQualityDOM(el) { } // --- Tiny body text --- + // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { - if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) { + const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; + const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const isUppercase = style.textTransform === 'uppercase'; + if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); } } @@ -990,6 +1015,7 @@ function checkTypography() { } for (const font of overusedFound) { + if (isBrandFontOnOwnDomain(font)) continue; findings.push({ type: 'overused-font', detail: `Primary font: ${font}` }); } if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) { @@ -1236,7 +1262,11 @@ function checkPageLayout(doc, win) { // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── if (IS_BROWSER) { - const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true'; + // Detect extension mode via the script tag's data attribute or the document element fallback. + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). + const _myScript = document.currentScript; + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') + || document.documentElement.dataset.impeccableExtension === 'true'; const BRAND_COLOR = 'oklch(55% 0.25 350)'; const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)'; @@ -1266,18 +1296,103 @@ if (IS_BROWSER) { outline-color: ${BRAND_COLOR_HOVER}; z-index: 100001 !important; } - .impeccable-label { - transition: background 0.15s ease; - } .impeccable-overlay.impeccable-hover .impeccable-label { background: ${BRAND_COLOR_HOVER}; } + .impeccable-overlay.impeccable-spotlight { + z-index: 100002 !important; + } + .impeccable-overlay.impeccable-spotlight-dimmed { + opacity: 0.15 !important; + animation: none !important; + filter: blur(3px); + } + .impeccable-spotlight-backdrop { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + backdrop-filter: blur(3px) brightness(0.6); + -webkit-backdrop-filter: blur(3px) brightness(0.6); + pointer-events: none; + z-index: 99998; + opacity: 0; + outline: none !important; + animation: none !important; + } + .impeccable-spotlight-backdrop.impeccable-visible { + opacity: 1; + } + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { + display: none !important; + } .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { display: none !important; } `; (document.head || document.documentElement).appendChild(styleEl); + // Spotlight backdrop element (created lazily on first use) + let spotlightBackdrop = null; + let spotlightTarget = null; + let spotlightTimer = null; + + function getSpotlightBackdrop() { + if (!spotlightBackdrop) { + spotlightBackdrop = document.createElement('div'); + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; + document.body.appendChild(spotlightBackdrop); + } + return spotlightBackdrop; + } + + function updateSpotlightClipPath() { + if (!spotlightBackdrop || !spotlightTarget) return; + const r = spotlightTarget.getBoundingClientRect(); + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) + const inset = 4; + const radius = 6; // outline border-radius (4) + outline width (2) + const x1 = r.left - inset; + const y1 = r.top - inset; + const x2 = r.right + inset; + const y2 = r.bottom + inset; + const vw = window.innerWidth; + const vh = window.innerHeight; + // Outer rect + rounded inner rect (evenodd creates a hole) + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; + } + + function showSpotlight(target) { + if (!target || !target.getBoundingClientRect) return; + // Respect the spotlightBlur setting: if disabled, don't show the backdrop + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { + spotlightTarget = target; + return; + } + spotlightTarget = target; + const bd = getSpotlightBackdrop(); + updateSpotlightClipPath(); + bd.classList.add('impeccable-visible'); + } + + function hideSpotlight() { + spotlightTarget = null; + if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); + } + + function isInViewport(el) { + const r = el.getBoundingClientRect(); + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; + } + + // Reposition spotlight on scroll/resize + window.addEventListener('scroll', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }, { passive: true }); + window.addEventListener('resize', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }); + const overlays = []; const TYPE_LABELS = {}; const RULE_CATEGORY = {}; @@ -1315,6 +1430,8 @@ if (IS_BROWSER) { function repositionOverlays() { for (const o of overlays) { if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; + // Skip overlays whose target is currently hidden (display: none on the overlay) + if (o.style.display === 'none') continue; positionOverlay(o); } } @@ -1325,6 +1442,13 @@ if (IS_BROWSER) { resizeRAF = requestAnimationFrame(repositionOverlays); }; window.addEventListener('resize', onResize); + // Reposition on scroll too -- catches sticky/parallax shifts + window.addEventListener('scroll', onResize, { passive: true }); + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) + if (typeof ResizeObserver !== 'undefined') { + const bodyResizeObserver = new ResizeObserver(onResize); + bodyResizeObserver.observe(document.body); + } // Track target element visibility via IntersectionObserver. // Uses a huge rootMargin so all *rendered* elements count as intersecting, @@ -1340,7 +1464,13 @@ if (IS_BROWSER) { positionOverlay(overlay); if (!overlay._revealed) { overlay._revealed = true; - overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`; + if (firstScanDone) { + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation + overlay.style.animation = 'none'; + } else { + // Initial scan: staggered cascade reveal + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; + } requestAnimationFrame(() => { overlay.classList.add('impeccable-visible'); if (overlay._checkLabel) overlay._checkLabel(); @@ -1606,6 +1736,13 @@ if (IS_BROWSER) { return parts.join(' > '); } + function isElementHidden(el) { + if (!el || el === document.body || el === document.documentElement) return false; + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) + return el.offsetWidth === 0 && el.offsetHeight === 0; + } + function serializeFindings(allFindings) { return allFindings.map(({ el, findings }) => ({ selector: generateSelector(el), @@ -1613,6 +1750,7 @@ if (IS_BROWSER) { rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) ? el.getBoundingClientRect().toJSON() : null, isPageLevel: el === document.body || el === document.documentElement, + isHidden: isElementHidden(el), findings: findings.map(f => { const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); return { @@ -1644,18 +1782,19 @@ if (IS_BROWSER) { console.groupEnd(); }; + let firstScanDone = false; const scan = function() { for (const o of overlays) o.remove(); overlays.length = 0; visibilityObserver.disconnect(); + overlayIndex = 0; const allFindings = []; const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); for (const el of document.querySelectorAll('*')) { - if (el.classList.contains('impeccable-overlay') || - el.classList.contains('impeccable-label') || - el.classList.contains('impeccable-tooltip')) continue; + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; // Skip browser extension elements (Claude, etc.) const elId = el.id || ''; if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; @@ -1733,6 +1872,9 @@ if (IS_BROWSER) { }, '*'); } + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) + setTimeout(() => { firstScanDone = true; }, 1000); + return allFindings; }; @@ -1754,8 +1896,43 @@ if (IS_BROWSER) { overlays.length = 0; visibilityObserver.disconnect(); styleEl.remove(); + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } document.body.classList.remove('impeccable-hidden'); } + if (e.data.action === 'highlight') { + if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } + try { + const target = e.data.selector ? document.querySelector(e.data.selector) : null; + if (target) { + // Scroll first so positionOverlay reads the post-scroll rect + if (!isInViewport(target) && target.scrollIntoView) { + target.scrollIntoView({ behavior: 'instant', block: 'center' }); + } + for (const o of overlays) { + if (o.classList.contains('impeccable-banner')) continue; + const isMatch = o._targetEl === target; + o.classList.toggle('impeccable-spotlight', isMatch); + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); + if (isMatch) { + // Force the matching overlay visible immediately, don't wait for IntersectionObserver + o.style.display = ''; + o.style.animation = 'none'; + o.classList.add('impeccable-visible'); + o._revealed = true; + positionOverlay(o); + } + } + showSpotlight(target); + } + } catch { /* invalid selector */ } + } + if (e.data.action === 'unhighlight') { + hideSpotlight(); + for (const o of overlays) { + o.classList.remove('impeccable-spotlight'); + o.classList.remove('impeccable-spotlight-dimmed'); + } + } }); window.postMessage({ source: 'impeccable-ready' }, '*'); } else { diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs index fa61afbc2..493d9a1a8 100644 --- a/src/detect-antipatterns.mjs +++ b/src/detect-antipatterns.mjs @@ -47,6 +47,26 @@ const OVERUSED_FONTS = new Set([ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', ]); +// Brand-associated fonts: don't flag these as "overused" on the brand's own domains. +// Keys are font names, values are arrays of hostname suffixes where the font is allowed. +const GOOGLE_DOMAINS = [ + 'google.com', 'youtube.com', 'android.com', 'chromium.org', + 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', +]; +const BRAND_FONT_DOMAINS = { + 'roboto': GOOGLE_DOMAINS, + 'google sans': GOOGLE_DOMAINS, + 'product sans': GOOGLE_DOMAINS, +}; + +function isBrandFontOnOwnDomain(font) { + if (typeof location === 'undefined') return false; + const allowed = BRAND_FONT_DOMAINS[font]; + if (!allowed) return false; + const host = location.hostname.toLowerCase(); + return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); +} + const GENERIC_FONTS = new Set([ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', @@ -803,11 +823,12 @@ function checkElementQualityDOM(el) { const rect = el.getBoundingClientRect(); // --- Line length too long --- - // Only flag if text is long enough to actually fill the line (>80 chars) - if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) { + // Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80) + const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80; + if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { const charsPerLine = rect.width / (fontSize * 0.5); - if (charsPerLine > 85) { - findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` }); + if (charsPerLine > lineMax + 5) { + findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` }); } } @@ -859,8 +880,12 @@ function checkElementQualityDOM(el) { } // --- Tiny body text --- + // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) if (hasDirectText && textLen > 20 && fontSize < 12) { - if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) { + const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; + const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); + const isUppercase = style.textTransform === 'uppercase'; + if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); } } @@ -985,6 +1010,7 @@ function checkTypography() { } for (const font of overusedFound) { + if (isBrandFontOnOwnDomain(font)) continue; findings.push({ type: 'overused-font', detail: `Primary font: ${font}` }); } if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) { @@ -1231,7 +1257,11 @@ function checkPageLayout(doc, win) { // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── if (IS_BROWSER) { - const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true'; + // Detect extension mode via the script tag's data attribute or the document element fallback. + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). + const _myScript = document.currentScript; + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') + || document.documentElement.dataset.impeccableExtension === 'true'; const BRAND_COLOR = 'oklch(55% 0.25 350)'; const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)'; @@ -1261,18 +1291,103 @@ if (IS_BROWSER) { outline-color: ${BRAND_COLOR_HOVER}; z-index: 100001 !important; } - .impeccable-label { - transition: background 0.15s ease; - } .impeccable-overlay.impeccable-hover .impeccable-label { background: ${BRAND_COLOR_HOVER}; } + .impeccable-overlay.impeccable-spotlight { + z-index: 100002 !important; + } + .impeccable-overlay.impeccable-spotlight-dimmed { + opacity: 0.15 !important; + animation: none !important; + filter: blur(3px); + } + .impeccable-spotlight-backdrop { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + backdrop-filter: blur(3px) brightness(0.6); + -webkit-backdrop-filter: blur(3px) brightness(0.6); + pointer-events: none; + z-index: 99998; + opacity: 0; + outline: none !important; + animation: none !important; + } + .impeccable-spotlight-backdrop.impeccable-visible { + opacity: 1; + } + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { + display: none !important; + } .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { display: none !important; } `; (document.head || document.documentElement).appendChild(styleEl); + // Spotlight backdrop element (created lazily on first use) + let spotlightBackdrop = null; + let spotlightTarget = null; + let spotlightTimer = null; + + function getSpotlightBackdrop() { + if (!spotlightBackdrop) { + spotlightBackdrop = document.createElement('div'); + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; + document.body.appendChild(spotlightBackdrop); + } + return spotlightBackdrop; + } + + function updateSpotlightClipPath() { + if (!spotlightBackdrop || !spotlightTarget) return; + const r = spotlightTarget.getBoundingClientRect(); + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) + const inset = 4; + const radius = 6; // outline border-radius (4) + outline width (2) + const x1 = r.left - inset; + const y1 = r.top - inset; + const x2 = r.right + inset; + const y2 = r.bottom + inset; + const vw = window.innerWidth; + const vh = window.innerHeight; + // Outer rect + rounded inner rect (evenodd creates a hole) + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; + } + + function showSpotlight(target) { + if (!target || !target.getBoundingClientRect) return; + // Respect the spotlightBlur setting: if disabled, don't show the backdrop + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { + spotlightTarget = target; + return; + } + spotlightTarget = target; + const bd = getSpotlightBackdrop(); + updateSpotlightClipPath(); + bd.classList.add('impeccable-visible'); + } + + function hideSpotlight() { + spotlightTarget = null; + if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); + } + + function isInViewport(el) { + const r = el.getBoundingClientRect(); + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; + } + + // Reposition spotlight on scroll/resize + window.addEventListener('scroll', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }, { passive: true }); + window.addEventListener('resize', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }); + const overlays = []; const TYPE_LABELS = {}; const RULE_CATEGORY = {}; @@ -1310,6 +1425,8 @@ if (IS_BROWSER) { function repositionOverlays() { for (const o of overlays) { if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; + // Skip overlays whose target is currently hidden (display: none on the overlay) + if (o.style.display === 'none') continue; positionOverlay(o); } } @@ -1320,6 +1437,13 @@ if (IS_BROWSER) { resizeRAF = requestAnimationFrame(repositionOverlays); }; window.addEventListener('resize', onResize); + // Reposition on scroll too -- catches sticky/parallax shifts + window.addEventListener('scroll', onResize, { passive: true }); + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) + if (typeof ResizeObserver !== 'undefined') { + const bodyResizeObserver = new ResizeObserver(onResize); + bodyResizeObserver.observe(document.body); + } // Track target element visibility via IntersectionObserver. // Uses a huge rootMargin so all *rendered* elements count as intersecting, @@ -1335,7 +1459,13 @@ if (IS_BROWSER) { positionOverlay(overlay); if (!overlay._revealed) { overlay._revealed = true; - overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`; + if (firstScanDone) { + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation + overlay.style.animation = 'none'; + } else { + // Initial scan: staggered cascade reveal + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; + } requestAnimationFrame(() => { overlay.classList.add('impeccable-visible'); if (overlay._checkLabel) overlay._checkLabel(); @@ -1601,6 +1731,13 @@ if (IS_BROWSER) { return parts.join(' > '); } + function isElementHidden(el) { + if (!el || el === document.body || el === document.documentElement) return false; + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) + return el.offsetWidth === 0 && el.offsetHeight === 0; + } + function serializeFindings(allFindings) { return allFindings.map(({ el, findings }) => ({ selector: generateSelector(el), @@ -1608,6 +1745,7 @@ if (IS_BROWSER) { rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) ? el.getBoundingClientRect().toJSON() : null, isPageLevel: el === document.body || el === document.documentElement, + isHidden: isElementHidden(el), findings: findings.map(f => { const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); return { @@ -1639,18 +1777,19 @@ if (IS_BROWSER) { console.groupEnd(); }; + let firstScanDone = false; const scan = function() { for (const o of overlays) o.remove(); overlays.length = 0; visibilityObserver.disconnect(); + overlayIndex = 0; const allFindings = []; const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); for (const el of document.querySelectorAll('*')) { - if (el.classList.contains('impeccable-overlay') || - el.classList.contains('impeccable-label') || - el.classList.contains('impeccable-tooltip')) continue; + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; // Skip browser extension elements (Claude, etc.) const elId = el.id || ''; if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; @@ -1728,6 +1867,9 @@ if (IS_BROWSER) { }, '*'); } + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) + setTimeout(() => { firstScanDone = true; }, 1000); + return allFindings; }; @@ -1749,8 +1891,43 @@ if (IS_BROWSER) { overlays.length = 0; visibilityObserver.disconnect(); styleEl.remove(); + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } document.body.classList.remove('impeccable-hidden'); } + if (e.data.action === 'highlight') { + if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } + try { + const target = e.data.selector ? document.querySelector(e.data.selector) : null; + if (target) { + // Scroll first so positionOverlay reads the post-scroll rect + if (!isInViewport(target) && target.scrollIntoView) { + target.scrollIntoView({ behavior: 'instant', block: 'center' }); + } + for (const o of overlays) { + if (o.classList.contains('impeccable-banner')) continue; + const isMatch = o._targetEl === target; + o.classList.toggle('impeccable-spotlight', isMatch); + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); + if (isMatch) { + // Force the matching overlay visible immediately, don't wait for IntersectionObserver + o.style.display = ''; + o.style.animation = 'none'; + o.classList.add('impeccable-visible'); + o._revealed = true; + positionOverlay(o); + } + } + showSpotlight(target); + } + } catch { /* invalid selector */ } + } + if (e.data.action === 'unhighlight') { + hideSpotlight(); + for (const o of overlays) { + o.classList.remove('impeccable-spotlight'); + o.classList.remove('impeccable-spotlight-dimmed'); + } + } }); window.postMessage({ source: 'impeccable-ready' }, '*'); } else {