From 13b2f763d92b565845a81c6c7ab66b8eb02b6666 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 6 Apr 2026 21:06:09 -0700 Subject: [PATCH] Polish Chrome extension for Web Store submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors the extension for on-demand injection (no static content_scripts entry — content script and detector are loaded only when the user actively opens the Impeccable panel, sidebar pane, or popup). Adds a new "Auto-scan" preference (default: scan when the Impeccable panel opens, opt-in: scan when DevTools opens) plus configurable line length (strict/lax) and highlight blur on/off settings. Adds an Elements panel sidebar that shows findings for the currently selected element. Includes substantial overlay UX work: page-pixel-perfect spotlight mask via clip-path, refined hover/dim states, instant transitions for snappier feel, copy buttons for findings, hover-from-panel highlighting, and a brand-aware exception list so the font check no longer flags Roboto on Google's own properties. Robustness fixes for the MV3 service worker lifecycle: heartbeat keepalive plus auto-reconnecting ports across panel/sidebar/devtools so transient SW restarts don't break the panel UI, and immediate teardown on DevTools close (replacing an unreliable setTimeout-based defer that didn't survive SW termination). Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/STORE_LISTING.md | 63 +++++++ extension/background/service-worker.js | 117 +++++++++--- extension/content/content-script.js | 185 ++++++++++-------- extension/devtools/devtools.js | 39 +++- extension/devtools/panel.css | 179 ++++++++++++++++-- extension/devtools/panel.html | 28 ++- extension/devtools/panel.js | 248 ++++++++++++++++++++++--- extension/devtools/sidebar.css | 97 ++++++++++ extension/devtools/sidebar.html | 13 ++ extension/devtools/sidebar.js | 103 ++++++++++ extension/icons/promo-small.png | Bin 0 -> 30235 bytes extension/manifest.json | 7 - public/privacy.html | 13 +- scripts/generate-promo-tile.js | 145 +++++++++++++++ src/detect-antipatterns-browser.js | 203 ++++++++++++++++++-- src/detect-antipatterns.mjs | 203 ++++++++++++++++++-- 16 files changed, 1465 insertions(+), 178 deletions(-) create mode 100644 extension/STORE_LISTING.md create mode 100644 extension/devtools/sidebar.css create mode 100644 extension/devtools/sidebar.html create mode 100644 extension/devtools/sidebar.js create mode 100644 extension/icons/promo-small.png create mode 100644 scripts/generate-promo-tile.js 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 0000000000000000000000000000000000000000..aa9f5ee9d3f15067c3b4ba0b7842ddedb0e2034a GIT binary patch literal 30235 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 m*4x_@lrbY@2jQ"], - "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:

+
    +
  • activeTab / scripting - to inject the detector script into the page you are inspecting
  • +
  • storage - to save your rule preferences
  • +
  • webNavigation - to re-scan automatically when you navigate to a new page
  • +
  • Host permissions (all URLs) - so the detector can run on any website you choose to inspect
  • +
+

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 {