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
+ - + +
+ +
+ Line length +
+ + +
+
+
+ Highlight blur + +
diff --git a/extension/devtools/panel.js b/extension/devtools/panel.js index 28ab0e027..23c34b1ce 100644 --- a/extension/devtools/panel.js +++ b/extension/devtools/panel.js @@ -11,13 +11,34 @@ if (chrome.devtools.panels.themeName === 'dark') { } const tabId = chrome.devtools.inspectedWindow.tabId; -const port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` }); + +// Auto-reconnecting port. Service workers in MV3 can be terminated after ~30s of +// inactivity (especially when the browser window is unfocused). When they restart, +// the existing port becomes invalid. We recreate it lazily on the next use. +let port = null; +function getPort() { + if (port) return port; + port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` }); + port.onMessage.addListener(handlePortMessage); + port.onDisconnect.addListener(() => { port = null; }); + return port; +} +function postToPort(msg) { + try { + getPort().postMessage(msg); + } catch { + // Port died mid-call. Drop it and try once more with a fresh port. + port = null; + try { getPort().postMessage(msg); } catch { /* give up silently */ } + } +} const badge = document.getElementById('badge'); const container = document.getElementById('findings-container'); const emptyState = document.getElementById('empty-state'); const btnRescan = document.getElementById('btn-rescan'); const btnToggle = document.getElementById('btn-toggle'); +const btnCopyAll = document.getElementById('btn-copy-all'); const settingsContainer = document.getElementById('settings-container'); const settingsList = document.getElementById('settings-list'); const btnSettings = document.getElementById('btn-settings'); @@ -25,6 +46,7 @@ const btnSettings = document.getElementById('btn-settings'); let overlaysVisible = true; let allAntipatterns = []; let disabledRules = []; +let currentFindings = []; // Load antipatterns list and disabled rules async function initSettings() { @@ -33,28 +55,100 @@ async function initSettings() { allAntipatterns = await resp.json(); } catch { allAntipatterns = []; } - const stored = await chrome.storage.sync.get({ disabledRules: [] }); + const stored = await chrome.storage.sync.get({ + disabledRules: [], + lineLengthMode: 'strict', + spotlightBlur: true, + autoScan: 'panel', + }); disabledRules = stored.disabledRules; renderSettings(); + initLineLengthControl(stored.lineLengthMode); + initSpotlightBlurToggle(stored.spotlightBlur); + initAutoScanControl(stored.autoScan); +} + +function initAutoScanControl(currentMode) { + const group = document.getElementById('auto-scan-mode'); + if (!group) return; + for (const btn of group.querySelectorAll('button')) { + btn.classList.toggle('active', btn.dataset.value === currentMode); + btn.addEventListener('click', async () => { + const mode = btn.dataset.value; + for (const b of group.querySelectorAll('button')) { + b.classList.toggle('active', b === btn); + } + await chrome.storage.sync.set({ autoScan: mode }); + }); + } +} + +function initLineLengthControl(currentMode) { + const group = document.getElementById('line-length-mode'); + if (!group) return; + for (const btn of group.querySelectorAll('button')) { + btn.classList.toggle('active', btn.dataset.value === currentMode); + btn.addEventListener('click', async () => { + const mode = btn.dataset.value; + for (const b of group.querySelectorAll('button')) { + b.classList.toggle('active', b === btn); + } + await chrome.storage.sync.set({ lineLengthMode: mode }); + chrome.runtime.sendMessage({ action: 'disabled-rules-changed' }); + }); + } +} + +function initSpotlightBlurToggle(currentValue) { + const cb = document.getElementById('spotlight-blur-toggle'); + if (!cb) return; + cb.checked = currentValue; + cb.addEventListener('change', async () => { + await chrome.storage.sync.set({ spotlightBlur: cb.checked }); + chrome.runtime.sendMessage({ action: 'disabled-rules-changed' }); + }); } function renderSettings() { settingsList.innerHTML = ''; + + const categories = { + slop: { label: 'AI tells', items: [] }, + quality: { label: 'Quality', items: [] }, + }; for (const ap of allAntipatterns) { - const label = document.createElement('label'); - label.className = 'setting-rule'; + const cat = ap.category || 'quality'; + (categories[cat] || categories.quality).items.push(ap); + } - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.checked = !disabledRules.includes(ap.id); - checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked)); + for (const [, group] of Object.entries(categories)) { + if (!group.items.length) continue; - const text = document.createElement('span'); - text.textContent = ap.name; + const header = document.createElement('div'); + header.className = 'settings-header'; + header.textContent = group.label; + settingsList.appendChild(header); - label.appendChild(checkbox); - label.appendChild(text); - settingsList.appendChild(label); + const grid = document.createElement('div'); + grid.className = 'settings-grid'; + + for (const ap of group.items) { + const label = document.createElement('label'); + label.className = 'setting-rule'; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = !disabledRules.includes(ap.id); + checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked)); + + const text = document.createElement('span'); + text.textContent = ap.name; + + label.appendChild(checkbox); + label.appendChild(text); + grid.appendChild(label); + } + settingsList.appendChild(grid); } } @@ -68,8 +162,13 @@ async function toggleRule(ruleId, enabled) { chrome.runtime.sendMessage({ action: 'disabled-rules-changed' }); } -// Listen for messages from the service worker -port.onMessage.addListener((msg) => { +// Listen for messages from the service worker (called by getPort() on each new connection) +function handlePortMessage(msg) { + if (msg.action === 'page-pointer-active') { + // Cursor is active on the page → user has left the panel + setHoveredItem(null); + return; + } if (msg.action === 'findings' || msg.action === 'state') { renderFindings(msg.findings || []); if (msg.overlaysVisible !== undefined) { @@ -84,16 +183,23 @@ port.onMessage.addListener((msg) => { if (msg.action === 'navigated') { showScanning(); } -}); +} + +// Initial connection +getPort(); + +// Heartbeat to keep the MV3 service worker alive while the panel is open. +// SWs can be terminated after ~30s of inactivity, especially when the browser is unfocused. +setInterval(() => postToPort({ action: 'ping' }), 20000); // Controls btnRescan.addEventListener('click', () => { showScanning(); - port.postMessage({ action: 'scan' }); + postToPort({ action: 'scan' }); }); btnToggle.addEventListener('click', () => { - port.postMessage({ action: 'toggle-overlays' }); + postToPort({ action: 'toggle-overlays' }); }); btnSettings.addEventListener('click', () => { @@ -103,8 +209,8 @@ btnSettings.addEventListener('click', () => { }); function updateToggleButton() { - btnToggle.classList.toggle('active', overlaysVisible); btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays'; + btnToggle.classList.toggle('inactive', !overlaysVisible); } function showScanning() { @@ -115,7 +221,86 @@ function showScanning() { `; } +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} +
+ ${escapeHtml(item.selector)} + +
${escapeHtml(item.detail)} ${escapeHtml(group.description)}`; - if (!item.isPageLevel) { + const copyBtn = itemEl.querySelector('.finding-copy'); + const finding = { name: group.name, description: group.description, detail: item.detail }; + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + copyToClipboard(formatSingleFindingForCopy(item, finding), copyBtn); + }); + + if (!item.isPageLevel && !item.isHidden) { + itemEl.dataset.selector = item.selector; itemEl.addEventListener('click', () => inspectElement(item.selector)); } diff --git a/extension/devtools/sidebar.css b/extension/devtools/sidebar.css new file mode 100644 index 000000000..563297fa7 --- /dev/null +++ b/extension/devtools/sidebar.css @@ -0,0 +1,97 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --bg: #fff; + --text: #1a1a1a; + --text-dim: #666; + --text-faint: #999; + --accent: oklch(48% 0.25 350); + --rule: #e8e6e2; +} + +.theme-dark { + --bg: #1a1a1a; + --text: #f5f3ef; + --text-dim: #9a9590; + --text-faint: #666; + --accent: oklch(60% 0.25 350); + --rule: #2a2a2a; +} + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; + font-size: 12px; + line-height: 1.5; + padding: 12px 14px; +} + +/* Empty / no-findings states */ + +.state { + color: var(--text-faint); + font-size: 12px; + font-style: italic; + padding: 4px 0; +} + +.state strong { + color: var(--text-dim); + font-style: normal; + font-weight: 600; +} + +/* Finding list */ + +.finding + .finding { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--rule); +} + +.finding-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.finding-name { + font-weight: 600; + font-size: 12px; + color: var(--text); + letter-spacing: -0.005em; +} + +.finding-name .marker { + color: var(--accent); + margin-right: 4px; +} + +.finding-kind { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + flex-shrink: 0; +} + +.finding-detail { + font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace; + font-size: 11px; + color: var(--text-dim); + margin-bottom: 6px; +} + +.finding-description { + font-size: 11px; + color: var(--text-dim); + line-height: 1.55; +} diff --git a/extension/devtools/sidebar.html b/extension/devtools/sidebar.html new file mode 100644 index 000000000..99ac18f4f --- /dev/null +++ b/extension/devtools/sidebar.html @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/extension/devtools/sidebar.js b/extension/devtools/sidebar.js new file mode 100644 index 000000000..bff8e95cc --- /dev/null +++ b/extension/devtools/sidebar.js @@ -0,0 +1,103 @@ +/** + * Impeccable DevTools Extension - Elements Sidebar Pane + * + * Shows Impeccable findings for the currently selected element ($0) in the Elements panel. + */ + +if (chrome.devtools.panels.themeName === 'dark') { + document.documentElement.classList.add('theme-dark'); +} + +const tabId = chrome.devtools.inspectedWindow.tabId; +const content = document.getElementById('sidebar-content'); +let currentFindings = []; + +// Auto-reconnecting port (service worker may restart in MV3) +let port = null; +function getPort() { + if (port) return port; + port = chrome.runtime.connect({ name: `impeccable-sidebar-${tabId}` }); + port.onMessage.addListener((msg) => { + if (msg.action === 'findings' || msg.action === 'state') { + currentFindings = msg.findings || []; + refreshForCurrentSelection(); + } + }); + port.onDisconnect.addListener(() => { port = null; }); + return port; +} +getPort(); + +chrome.devtools.panels.elements.onSelectionChanged.addListener(refreshForCurrentSelection); + +function refreshForCurrentSelection() { + if (!currentFindings.length) { + renderEmpty('No findings on this page yet.'); + return; + } + + // Collect non-page-level selectors and ask the inspected window which one matches $0 + const selectors = []; + for (const item of currentFindings) { + if (item.isPageLevel || item.isHidden) continue; + selectors.push(item.selector); + } + if (!selectors.length) { + renderEmpty('No element-level findings on this page.'); + return; + } + + const code = `(function() { + var sels = ${JSON.stringify(selectors)}; + var matched = []; + for (var i = 0; i < sels.length; i++) { + try { if (document.querySelector(sels[i]) === $0) matched.push(sels[i]); } catch (e) {} + } + return matched; + })()`; + + chrome.devtools.inspectedWindow.eval(code, (matched) => { + if (!matched || !matched.length) { + renderNoFindings(); + return; + } + const items = currentFindings.filter(item => matched.includes(item.selector)); + render(items); + }); +} + +function renderEmpty(text) { + content.innerHTML = `
${escapeHtml(text)}
`; +} + +function renderNoFindings() { + content.innerHTML = `
Clean. No anti-patterns on this element.
`; +} + +function render(items) { + const html = []; + for (const item of items) { + for (const f of item.findings) { + const isSlop = f.category === 'slop'; + const marker = isSlop ? '\u2726' : ''; + const kind = isSlop ? 'AI tell' : 'Quality'; + html.push(` +
+
+ ${marker}${escapeHtml(f.name)} + ${kind} +
+
${escapeHtml(f.detail)}
+
${escapeHtml(f.description)}
+
+ `); + } + } + content.innerHTML = html.join(''); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/extension/icons/promo-small.png b/extension/icons/promo-small.png new file mode 100644 index 000000000..aa9f5ee9d Binary files /dev/null and b/extension/icons/promo-small.png differ diff --git a/extension/manifest.json b/extension/manifest.json index 3ccd291eb..c1ae628e4 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -8,13 +8,6 @@ "background": { "service_worker": "background/service-worker.js" }, - "content_scripts": [ - { - "matches": [""], - "js": ["content/content-script.js"], - "run_at": "document_idle" - } - ], "devtools_page": "devtools/devtools.html", "action": { "default_popup": "popup/popup.html", diff --git a/public/privacy.html b/public/privacy.html index 528dab42f..6109c4694 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -18,7 +18,7 @@ ← Back to impeccable.style

Privacy Policy

-

Last updated: March 24, 2026

+

Last updated: April 6, 2026

What Impeccable is

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 @@

Claude Code Plugin

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.

+

Chrome Extension

+

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:

+ +

GitHub

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 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Impeccable + + + + + + DEVTOOLS + + + + + + + + + + + + + + + + AI-Powered Magic + + + Reimagining the future of everything + + + + + + + + ✦ gradient text + + + + + + Detect AI slop in any web page. + + + 24 detections · Open DevTools and see what needs fixing. + + +`; + +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 {