From e961d56252bfee2c8791baac7ca2a8853f022311 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 6 Apr 2026 15:18:36 -0700 Subject: [PATCH] Add Chrome DevTools extension for anti-pattern detection Adds a Manifest V3 Chrome extension that injects the detector when DevTools opens, with a dedicated panel for browsing findings, a toolbar popup for quick scan/toggle, and per-rule settings synced via chrome.storage. Categorizes anti-patterns into AI slop vs quality issues with visual differentiation (sparkle prefix, panel grouping). Overlay labels are polished with flush positioning, cycling for multi-finding elements, and synchronized hover darkening. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 + extension/background/service-worker.js | 192 ++++++++++++ extension/content/content-script.js | 98 ++++++ extension/devtools/devtools.html | 5 + extension/devtools/devtools.js | 21 ++ extension/devtools/panel.css | 365 ++++++++++++++++++++++ extension/devtools/panel.html | 42 +++ extension/devtools/panel.js | 227 ++++++++++++++ extension/icons/icon-128.png | Bin 0 -> 1744 bytes extension/icons/icon-16.png | Bin 0 -> 287 bytes extension/icons/icon-32.png | Bin 0 -> 474 bytes extension/icons/icon-48.png | Bin 0 -> 621 bytes extension/icons/icon.svg | 4 + extension/manifest.json | 39 +++ extension/popup/popup.css | 110 +++++++ extension/popup/popup.html | 29 ++ extension/popup/popup.js | 67 ++++ package.json | 1 + scripts/build-extension.js | 82 +++++ scripts/generate-extension-icons.js | 38 +++ src/detect-antipatterns-browser.js | 406 ++++++++++++++++++------- src/detect-antipatterns.mjs | 406 ++++++++++++++++++------- 22 files changed, 1921 insertions(+), 214 deletions(-) create mode 100644 extension/background/service-worker.js create mode 100644 extension/content/content-script.js create mode 100644 extension/devtools/devtools.html create mode 100644 extension/devtools/devtools.js create mode 100644 extension/devtools/panel.css create mode 100644 extension/devtools/panel.html create mode 100644 extension/devtools/panel.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/icons/icon.svg create mode 100644 extension/manifest.json create mode 100644 extension/popup/popup.css create mode 100644 extension/popup/popup.html create mode 100644 extension/popup/popup.js create mode 100644 scripts/build-extension.js create mode 100644 scripts/generate-extension-icons.js diff --git a/.gitignore b/.gitignore index 51d4e75c7..536039899 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,8 @@ Thumbs.db # Cloudflare .wrangler/ +# Extension build artifacts +extension/detector/ + # User design context .impeccable.md diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js new file mode 100644 index 000000000..488072c17 --- /dev/null +++ b/extension/background/service-worker.js @@ -0,0 +1,192 @@ +/** + * Impeccable DevTools Extension - Service Worker + * + * Routes messages between popup, DevTools panel, and content scripts. + * Maintains per-tab state and updates the badge. + */ + +// Per-tab state: { tabId: { findings, overlaysVisible, injected } } +const tabState = new Map(); + +// Active DevTools panel connections: { tabId: Set } +const panelPorts = new Map(); + +function getState(tabId) { + if (!tabState.has(tabId)) { + tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false }); + } + return tabState.get(tabId); +} + +function updateBadge(tabId) { + const state = tabState.get(tabId); + const count = state?.findings?.length || 0; + const text = count > 0 ? String(count) : ''; + chrome.action.setBadgeText({ text, tabId }).catch(() => {}); + chrome.action.setBadgeBackgroundColor({ color: '#d6336c', tabId }).catch(() => {}); +} + +function notifyPanels(tabId, message) { + const ports = panelPorts.get(tabId); + if (ports) { + for (const port of ports) { + try { port.postMessage(message); } catch { /* port disconnected */ } + } + } +} + +async function getDisabledRules() { + const result = await chrome.storage.sync.get({ disabledRules: [] }); + return result.disabledRules; +} + +async function buildScanConfig() { + const disabledRules = await getDisabledRules(); + return disabledRules.length ? { disabledRules } : null; +} + +async function sendScanToTab(tabId) { + const config = await buildScanConfig(); + chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {}); +} + +// Handle messages from content scripts and popup +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + const tabId = msg.tabId || sender.tab?.id; + + if (msg.action === 'findings' && tabId) { + const state = getState(tabId); + state.findings = msg.findings || []; + state.injected = true; + updateBadge(tabId); + notifyPanels(tabId, { action: 'findings', findings: state.findings }); + // Broadcast for popup + chrome.runtime.sendMessage({ action: 'findings-updated', tabId, findings: state.findings }).catch(() => {}); + sendResponse({ ok: true }); + } + + else if (msg.action === 'scan' && tabId) { + sendScanToTab(tabId); + sendResponse({ ok: true }); + } + + else if (msg.action === 'toggle-overlays' && tabId) { + chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {}); + sendResponse({ ok: true }); + } + + else if (msg.action === 'overlays-toggled' && tabId) { + const state = getState(tabId); + state.overlaysVisible = msg.visible; + notifyPanels(tabId, { action: 'overlays-toggled', visible: msg.visible }); + chrome.runtime.sendMessage({ action: 'overlays-toggled-broadcast', tabId, visible: msg.visible }).catch(() => {}); + sendResponse({ ok: true }); + } + + else if (msg.action === 'get-state' && tabId) { + sendResponse(getState(tabId)); + } + + else if (msg.action === 'inject-fallback' && tabId) { + // CSP fallback: inject detector via chrome.scripting (bypasses page CSP) + chrome.scripting.executeScript({ + target: { tabId }, + world: 'MAIN', + files: ['detector/detect.js'], + }).then(() => { + // Detector will post impeccable-ready, content script handles the rest + }).catch((err) => { + console.warn('[impeccable] Fallback injection failed:', err); + }); + sendResponse({ ok: true }); + } + + else if (msg.action === 'disabled-rules-changed') { + // Re-scan all tabs that have been injected + for (const [tid, state] of tabState) { + if (state.injected) sendScanToTab(tid); + } + sendResponse({ ok: true }); + } + + return true; +}); + +// Track which tabs have DevTools open (via the devtools.js lifecycle port) +const devtoolsTabs = new Set(); + +// Handle long-lived connections from DevTools pages and panels +chrome.runtime.onConnect.addListener((port) => { + // Lifecycle port from devtools.js -- tracks DevTools open/close + if (port.name.startsWith('impeccable-devtools-')) { + const tabId = parseInt(port.name.replace('impeccable-devtools-', ''), 10); + devtoolsTabs.add(tabId); + + port.onMessage.addListener((msg) => { + if (msg.action === 'scan') sendScanToTab(tabId); + }); + + 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); + }); + } + + // Panel port from panel.js -- for forwarding findings/state + if (port.name.startsWith('impeccable-panel-')) { + const tabId = parseInt(port.name.replace('impeccable-panel-', ''), 10); + if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set()); + panelPorts.get(tabId).add(port); + + // Send current state to newly connected panel + const state = getState(tabId); + port.postMessage({ action: 'state', ...state }); + + // If no findings yet, the auto-scan from devtools.js may have been lost -- trigger one + if (!state.findings.length) { + sendScanToTab(tabId); + } + + port.onMessage.addListener((msg) => { + if (msg.action === 'scan') { + sendScanToTab(tabId); + } else if (msg.action === 'toggle-overlays') { + chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {}); + } + }); + + 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) +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 + setTimeout(() => sendScanToTab(details.tabId), 300); + } +}); + +// Clean up state when tabs close +chrome.tabs.onRemoved.addListener((tabId) => { + tabState.delete(tabId); + panelPorts.delete(tabId); +}); diff --git a/extension/content/content-script.js b/extension/content/content-script.js new file mode 100644 index 000000000..4fb37b51b --- /dev/null +++ b/extension/content/content-script.js @@ -0,0 +1,98 @@ +/** + * Impeccable DevTools Extension - Content Script + * + * 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. + */ + +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 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-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(); + } + } +}); + +// 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; + } + + // 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'); + 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.html b/extension/devtools/devtools.html new file mode 100644 index 000000000..e2a45e1ff --- /dev/null +++ b/extension/devtools/devtools.html @@ -0,0 +1,5 @@ + + + + + diff --git a/extension/devtools/devtools.js b/extension/devtools/devtools.js new file mode 100644 index 000000000..bbd6bc75f --- /dev/null +++ b/extension/devtools/devtools.js @@ -0,0 +1,21 @@ +/** + * Impeccable DevTools Extension - DevTools Page + * + * Creates the Impeccable panel and triggers an auto-scan when DevTools opens. + * This page lives for the entire DevTools session -- its port disconnect + * is the canonical signal that DevTools has closed. + */ + +chrome.devtools.panels.create( + 'Impeccable', + 'icons/icon-32.png', + '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}`, +}); + +// Auto-scan when DevTools opens (regardless of which panel is active). +port.postMessage({ action: 'scan' }); diff --git a/extension/devtools/panel.css b/extension/devtools/panel.css new file mode 100644 index 000000000..aee800404 --- /dev/null +++ b/extension/devtools/panel.css @@ -0,0 +1,365 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* Light theme (default DevTools) */ +:root { + --bg: #fff; + --bg-subtle: #f5f5f5; + --bg-hover: #eee; + --text: #1a1a1a; + --text-dim: #666; + --accent: oklch(48% 0.25 350); + --accent-dim: oklch(40% 0.18 350); + --border: #ddd; + --radius: 6px; +} + +/* Dark theme (set via JS from chrome.devtools.panels.themeName) */ +.theme-dark { + --bg: #1a1a1a; + --bg-subtle: #242424; + --bg-hover: #2a2a2a; + --text: #f5f3ef; + --text-dim: #999; + --accent: oklch(55% 0.25 350); + --accent-dim: oklch(45% 0.18 350); + --border: #333; +} + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, sans-serif; + font-size: 12px; + line-height: 1.5; + overflow-y: auto; + height: 100vh; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 10; +} + +.toolbar-left { + display: flex; + align-items: center; + gap: 8px; +} + +.toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.logo { + font-size: 18px; + font-weight: 500; + color: var(--text); + opacity: 0.7; +} + +h1 { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; +} + +.badge { + background: var(--accent); + color: white; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + min-width: 20px; + text-align: center; + display: none; +} + +.badge.visible { + display: inline-block; +} + +/* Tool buttons */ +.tool-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--radius); + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.tool-btn:hover { + background: var(--bg-hover); + color: var(--text); +} + +.tool-btn.active { + color: var(--accent); +} + +.tool-btn.active:hover { + color: var(--text); +} + +/* Findings */ +#findings-container { + padding: 8px; +} + +.finding-group { + margin-bottom: 2px; +} + +.group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--radius); + cursor: pointer; + user-select: none; + transition: background 0.15s; +} + +.group-header:hover { + background: var(--bg-hover); +} + +.group-chevron { + font-size: 10px; + color: var(--text-dim); + transition: transform 0.15s; + width: 12px; + flex-shrink: 0; +} + +.group-header.collapsed .group-chevron { + transform: rotate(-90deg); +} + +.group-name { + font-weight: 600; + font-size: 12px; + flex: 1; + min-width: 0; +} + +.group-count { + font-size: 11px; + color: var(--text-dim); + font-weight: 500; +} + +.group-items { + overflow: hidden; +} + +.group-header.collapsed + .group-items { + display: none; +} + +.finding-item { + display: flex; + flex-direction: column; + gap: 2px; + padding: 5px 8px 5px 28px; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.15s; +} + +.finding-item:hover { + background: var(--bg-hover); +} + +.finding-selector { + font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace; + font-size: 11px; + color: var(--accent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.finding-detail { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.finding-description { + font-size: 11px; + color: var(--text-dim); + opacity: 0.7; + line-height: 1.4; + padding: 2px 0 4px; + display: none; +} + +.finding-item:hover .finding-description { + display: block; +} + +/* Page-level findings */ +.page-level-tag { + font-size: 10px; + font-weight: 500; + color: var(--accent-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Empty state */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; +} + +.empty-icon { + font-size: 32px; + font-weight: 500; + opacity: 0.3; + margin-bottom: 12px; +} + +.empty-title { + font-size: 13px; + font-weight: 500; + margin-bottom: 4px; +} + +.empty-hint { + font-size: 12px; + color: var(--text-dim); +} + +/* Category sections */ +.category-section { + margin-bottom: 4px; +} + +.category-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 8px 4px; +} + +.category-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.category-dot-slop { + background: oklch(55% 0.25 350); +} + +.category-dot-quality { + background: var(--text-dim); +} + +.category-name { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-dim); +} + +.category-count { + font-size: 11px; + color: var(--text-dim); + font-weight: 500; +} + +/* Settings */ +#settings-container { + border-bottom: 1px solid var(--border); + padding: 0 8px 8px; +} + +.settings-header { + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 8px 8px 6px; +} + +#settings-list { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px 12px; +} + +.setting-rule { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: var(--radius); + font-size: 11px; + cursor: pointer; + transition: background 0.15s; +} + +.setting-rule:hover { + background: var(--bg-hover); +} + +.setting-rule input[type="checkbox"] { + margin: 0; + accent-color: var(--accent); +} + +/* Scanning state */ +.scanning-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + color: var(--text-dim); + font-size: 12px; +} + +.scanning-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 1; } +} diff --git a/extension/devtools/panel.html b/extension/devtools/panel.html new file mode 100644 index 000000000..ac6799bd5 --- /dev/null +++ b/extension/devtools/panel.html @@ -0,0 +1,42 @@ + + + + + + + +
+
+ +

Impeccable

+ 0 +
+
+ + + +
+
+ + + +
+
+
/
+

No anti-patterns detected

+

Overlays will appear on the page when issues are found

+
+
+ + + + diff --git a/extension/devtools/panel.js b/extension/devtools/panel.js new file mode 100644 index 000000000..28ab0e027 --- /dev/null +++ b/extension/devtools/panel.js @@ -0,0 +1,227 @@ +/** + * Impeccable DevTools Extension - Panel + * + * Displays findings, provides controls for scanning and overlay toggling, + * and allows clicking findings to inspect elements. + */ + +// Match the DevTools theme (light or dark) +if (chrome.devtools.panels.themeName === 'dark') { + document.documentElement.classList.add('theme-dark'); +} + +const tabId = chrome.devtools.inspectedWindow.tabId; +const port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` }); + +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 settingsContainer = document.getElementById('settings-container'); +const settingsList = document.getElementById('settings-list'); +const btnSettings = document.getElementById('btn-settings'); + +let overlaysVisible = true; +let allAntipatterns = []; +let disabledRules = []; + +// Load antipatterns list and disabled rules +async function initSettings() { + try { + const resp = await fetch(chrome.runtime.getURL('detector/antipatterns.json')); + allAntipatterns = await resp.json(); + } catch { allAntipatterns = []; } + + const stored = await chrome.storage.sync.get({ disabledRules: [] }); + disabledRules = stored.disabledRules; + renderSettings(); +} + +function renderSettings() { + settingsList.innerHTML = ''; + for (const ap of allAntipatterns) { + 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); + settingsList.appendChild(label); + } +} + +async function toggleRule(ruleId, enabled) { + if (enabled) { + disabledRules = disabledRules.filter(id => id !== ruleId); + } else { + if (!disabledRules.includes(ruleId)) disabledRules.push(ruleId); + } + await chrome.storage.sync.set({ disabledRules }); + chrome.runtime.sendMessage({ action: 'disabled-rules-changed' }); +} + +// Listen for messages from the service worker +port.onMessage.addListener((msg) => { + if (msg.action === 'findings' || msg.action === 'state') { + renderFindings(msg.findings || []); + if (msg.overlaysVisible !== undefined) { + overlaysVisible = msg.overlaysVisible; + updateToggleButton(); + } + } + if (msg.action === 'overlays-toggled') { + overlaysVisible = msg.visible; + updateToggleButton(); + } + if (msg.action === 'navigated') { + showScanning(); + } +}); + +// Controls +btnRescan.addEventListener('click', () => { + showScanning(); + port.postMessage({ action: 'scan' }); +}); + +btnToggle.addEventListener('click', () => { + port.postMessage({ action: 'toggle-overlays' }); +}); + +btnSettings.addEventListener('click', () => { + const isVisible = settingsContainer.style.display !== 'none'; + settingsContainer.style.display = isVisible ? 'none' : ''; + btnSettings.classList.toggle('active', !isVisible); +}); + +function updateToggleButton() { + btnToggle.classList.toggle('active', overlaysVisible); + btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays'; +} + +function showScanning() { + container.innerHTML = ` +
+
+ Scanning page... +
`; +} + +function renderFindings(findings) { + if (!findings.length) { + container.innerHTML = ''; + container.appendChild(emptyState); + emptyState.style.display = ''; + badge.classList.remove('visible'); + badge.textContent = '0'; + return; + } + + emptyState.style.display = 'none'; + + // Count total element-level findings + const totalCount = findings.reduce((sum, f) => sum + f.findings.length, 0); + badge.textContent = String(totalCount); + badge.classList.add('visible'); + + // Group findings by category, then by anti-pattern type + const categories = { slop: new Map(), quality: new Map() }; + for (const item of findings) { + for (const f of item.findings) { + const cat = f.category || 'quality'; + const groups = categories[cat] || categories.quality; + if (!groups.has(f.type)) { + groups.set(f.type, { name: f.name, description: f.description, items: [] }); + } + groups.get(f.type).items.push({ + selector: item.selector, + tagName: item.tagName, + isPageLevel: item.isPageLevel, + detail: f.detail, + }); + } + } + + container.innerHTML = ''; + + const CATEGORY_LABELS = { slop: 'AI tells', quality: 'Quality issues' }; + for (const [catKey, groups] of Object.entries(categories)) { + if (groups.size === 0) continue; + + const catCount = [...groups.values()].reduce((sum, g) => sum + g.items.length, 0); + const section = document.createElement('div'); + section.className = 'category-section category-' + catKey; + + const catHeader = document.createElement('div'); + catHeader.className = 'category-header'; + catHeader.innerHTML = ` + + ${CATEGORY_LABELS[catKey]} + ${catCount}`; + section.appendChild(catHeader); + + for (const [type, group] of groups) { + const groupEl = document.createElement('div'); + groupEl.className = 'finding-group'; + + const header = document.createElement('div'); + header.className = 'group-header'; + header.innerHTML = ` + + ${escapeHtml(group.name)} + ${group.items.length}`; + header.addEventListener('click', () => header.classList.toggle('collapsed')); + groupEl.appendChild(header); + + const itemsEl = document.createElement('div'); + itemsEl.className = 'group-items'; + + for (const item of group.items) { + const itemEl = document.createElement('div'); + itemEl.className = 'finding-item'; + itemEl.innerHTML = ` + ${item.isPageLevel ? 'page' : ''} + ${escapeHtml(item.selector)} + ${escapeHtml(item.detail)} + ${escapeHtml(group.description)}`; + + if (!item.isPageLevel) { + itemEl.addEventListener('click', () => inspectElement(item.selector)); + } + + itemsEl.appendChild(itemEl); + } + + groupEl.appendChild(itemsEl); + section.appendChild(groupEl); + } + + container.appendChild(section); + } +} + +function inspectElement(selector) { + const escaped = selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + chrome.devtools.inspectedWindow.eval( + `(function() { + var el = document.querySelector('${escaped}'); + if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); inspect(el); } + })()` + ); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +initSettings(); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..0f59fc1c6d0e2fd9e286f8d43fef3ae456a5dd66 GIT binary patch literal 1744 zcmZWqdpOkj8vo6hVTMFA7?-0Nb{p#&xrUf58JEm3N+C+3O=1nPMKf$JHDY8OTA5L~ zCHIZkFoSBdYa5AGkzv)*6gw`H%P_w=Kj*J=p7Y1|^L^g;{e0fb^L(H0dyN|4t*)xC z3IKq*57{#ax|!Ptr3~SK+o|6HKsnpT^I&k=?fKh6O8612Uxh=2a?Hg&L&H@U(XapX z4D)|_Yu(%;9!m>pvH9gYfBy%5a+SAjMu)d<>AtZi);!C;=8idZKECx@P^RH)@36+9y84UTN9DHsrtass&NW{a|(%XF<&0#ilwajzCjx`|);kHrIQ zZQYBN2_)d%yLX)5k-1eHt_bRl{3}y7aX-Yz#j#?RixlhsqAJ1Qi~nTomY0_+*xT%` z@D?ESH$DIhU6hJx%beip6iYP{4E|8?XuX`VXwul!SfoQBsim(lA_o6z-ibI%u#mqB z29c;?X0}v_gxj4olG8JRokvI9aj5B&d;ZU`x2R@VX;$V`Z?i`iYZ=boP`R>9|6{V+ zgfEdQa5iJ9)@EC#6$F-8fuG87q!E5YEX?U%8i*TGBi!SgTgxYo-U7QwOm+|BhM1>@ zc)l?kxlpDfI47Hn3I!gBd8~L?ehtGa2HA2O>?+e1Btd}?U;qmEz#3{9RzxI~j-$iLvQh^wB)|$v!2{Me!LHX>0&7_kd22NQ2jI(+kN=*85|?p3*lJ7%%X2;M-;KOP=>7kcgG z5vAigxKdQwaZ2<)Jw4*yz#%<-{i>I9!Xv_Jvpih_hZn8hLQ+MrUl!~VIj9X*R8;JT z6N`FFRhbzCzdiztAy2(4>Vv;@nAW^KTxt#4|Jb)I4scm;?}H6wa~#Y zox0A{t~r;{Yt9tV`%~b=#LY_*1I}+BmPI{1J=$&=Ma)plwI{i8adDz~`QxRWWM{d# z$j*ag*j}Qb{~b4|vv$I`vZSUr9>Y}c64v-@QnRJ^rSY?$E%{zW<4P;tyT4fy>t?*3W)LM=zp#_1wz%w#&+Je3*3Yq8C<$e=LN%MV9w_nd< zvd5#mitb`-uC&R>n>;20Li)<4bL=G~wz*^=VkR(*f5P zxfU#fhFF8{($`oxu?wz}>DpzfA!$kst2^+tq*R{%)JzhME|OUl~|NeSpU W+0FT(S7iqB06ty;o=qMnv;GZp;$v~u)-(H)! zTW!q&VTq{nO5VlF7oP{#2&vy;6)$BC3u7yLlzZgpjcF5psG8>%Un)MI8zkNGyplai zNK5$fig}^pYO04EB&|Qx9{K2{HrvdkKu+;I_sVs(Cg&tR zJyN?ml{4nLs|1_IGO2=dQ$B8G^A>x!>V=-c|7i_@-#4wfuuWG@vcTvBcWHTJNlSUn z*FW;j|E5n!6%Q%p<=y+#;@ljwi`$FpZ48^PyxR57N6pwuX9Z7>^Af+K<#mh<4FCU& j)lXao^dB3@f6NTY!Zvz(TcTzGMHoC?{an^LB{Ts5gxzsh literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..ab6d0fc9b89c28eb50f2df0aaeae539b2ad1d13d GIT binary patch literal 474 zcmV<00VV#4P)S6opTlF580LStwY9y0wB`s7*oo3NE^FFM>Ot!lj6hkcF>Mgw&r!1>@F&L^rMOJQ*cGD!y~JX?iDCJ^f3?j^M4<2FBxKG@DIGQmH`$BhyT-_Ur+&Xc90?1=wW?7!BdNF5VXl$WjH9 zqr(H#>vg5s6Kn@)_Jm6Q^#~9PFbqRHK0QMPRtk1{J#@QU%Jvg12mbA!K^2|=lb3&% z0IGh2!9M2m7c7@wP(@jQj@dH>WaZZ`OF&ZnK9#Cp9;M&#ulg}o{|Y4sIGc|i0m6Px z^~)ojg0%W|walK7c7VJ2@CBIs@|%ZJ5X*cJ1OcYgdsg+!5!0#o6~#6sc^q=ULU~xp zk-Q`q9MPoiCBGpxiu3}-;bbw_o02lx^Ire}0RR8AViwc@000I_L_t&o0Bzca9%$zF Q_y7O^07*qoM6N<$f>ut_N&o-= literal 0 HcmV?d00001 diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..a38563534ae46e03024c585d0a5ac74624716640 GIT binary patch literal 621 zcmV-z0+RiSP)>iigd^>adL1w4%1NC~n9YxXhcsyRFX#@Yu3&nCrv7Gk%{qyf%MsiZC z)tYqkE)7>PnRH0V&R{U;<`+H%(5i3J$2r2FP1(5MK`J0y`FjKeWunEIDDfmKo1n;J zj`)S8&ohL{R*82T1PaGq%C<_c2V!G=4O(#Q{0=nW#PQJq4)*tOa(oCS8q;v1TrMMy zS3V|VsNp6cUS0VdC!G$o@DmVA0k_b?Jp+%APk4QOgBC6VTFoY^)oMD{Tz$qxK%smR zz4~W-1Z3qi91d~ceSjwB0%m7tFh4(+j1R7MFVw>$qE9cp&RX`h(e_XvzU=2Sp-_MnY$Xy@l8O8FH&d%*2`8ruJe z|2urxqm`eNV4|Py3hB7bI{*Lx|NpBcPuu_i00v1!K~w_(p + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 000000000..3ccd291eb --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,39 @@ +{ + "manifest_version": 3, + "name": "Impeccable", + "description": "Detect common UI anti-patterns in any web page", + "version": "1.0.0", + "permissions": ["activeTab", "scripting", "storage", "webNavigation"], + "host_permissions": [""], + "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", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "web_accessible_resources": [ + { + "resources": ["detector/detect.js"], + "matches": [""] + } + ] +} diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 000000000..e6d78b6eb --- /dev/null +++ b/extension/popup/popup.css @@ -0,0 +1,110 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 220px; + background: #1a1a1a; + color: #f5f3ef; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + padding: 16px; +} + +header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.logo { + font-size: 18px; + font-weight: 500; + opacity: 0.7; +} + +h1 { + font-size: 14px; + font-weight: 600; +} + +.count-display { + text-align: center; + padding: 16px 0; + margin-bottom: 16px; +} + +.count-number { + display: block; + font-size: 36px; + font-weight: 700; + line-height: 1; + margin-bottom: 4px; + color: #999; + transition: color 0.2s; +} + +.count-number.has-findings { + color: oklch(55% 0.25 350); +} + +.count-label { + font-size: 12px; + color: #999; +} + +.actions { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.btn { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.btn-primary { + background: oklch(55% 0.25 350); + color: white; +} + +.btn-primary:hover { + background: oklch(50% 0.25 350); +} + +.btn-secondary { + background: #333; + color: #ccc; +} + +.btn-secondary:hover { + background: #3a3a3a; +} + +footer { + text-align: center; + padding-top: 8px; + border-top: 1px solid #333; +} + +footer a { + font-size: 11px; + color: #666; + text-decoration: none; +} + +footer a:hover { + color: #999; +} diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 000000000..591cd908e --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,29 @@ + + + + + + + +
+ +

Impeccable

+
+ +
+ 0 + anti-patterns +
+ +
+ + +
+ + + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 000000000..c8779df13 --- /dev/null +++ b/extension/popup/popup.js @@ -0,0 +1,67 @@ +/** + * Impeccable DevTools Extension - Popup + * + * Quick controls: scan, toggle overlays, and see finding count. + */ + +const countNumber = document.getElementById('count-number'); +const countLabel = document.getElementById('count-label'); +const btnScan = document.getElementById('btn-scan'); +const btnToggle = document.getElementById('btn-toggle'); + +let overlaysVisible = true; + +async function getActiveTabId() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab?.id; +} + +function updateFromState(state) { + if (!state) return; + const count = state.findings?.reduce((sum, f) => sum + f.findings.length, 0) || 0; + countNumber.textContent = String(count); + countNumber.classList.toggle('has-findings', count > 0); + countLabel.textContent = count === 1 ? 'anti-pattern' : 'anti-patterns'; + overlaysVisible = state.overlaysVisible !== false; + btnToggle.textContent = overlaysVisible ? 'Hide overlays' : 'Show overlays'; +} + +async function loadState() { + const tabId = await getActiveTabId(); + if (!tabId) return; + chrome.runtime.sendMessage({ action: 'get-state', tabId }, updateFromState); +} + +// Listen for real-time updates from service worker +chrome.runtime.onMessage.addListener((msg) => { + if (msg.action === 'findings-updated') { + const count = msg.findings?.reduce((sum, f) => sum + f.findings.length, 0) || 0; + countNumber.textContent = String(count); + countNumber.classList.toggle('has-findings', count > 0); + countLabel.textContent = count === 1 ? 'anti-pattern' : 'anti-patterns'; + btnScan.textContent = 'Scan page'; + btnScan.disabled = false; + } + if (msg.action === 'overlays-toggled-broadcast') { + overlaysVisible = msg.visible; + btnToggle.textContent = overlaysVisible ? 'Hide overlays' : 'Show overlays'; + } +}); + +btnScan.addEventListener('click', async () => { + const tabId = await getActiveTabId(); + if (!tabId) return; + btnScan.textContent = 'Scanning...'; + btnScan.disabled = true; + chrome.runtime.sendMessage({ action: 'scan', tabId }); +}); + +btnToggle.addEventListener('click', async () => { + const tabId = await getActiveTabId(); + if (!tabId) return; + chrome.runtime.sendMessage({ action: 'toggle-overlays', tabId }); + overlaysVisible = !overlaysVisible; + btnToggle.textContent = overlaysVisible ? 'Hide overlays' : 'Show overlays'; +}); + +loadState(); diff --git a/package.json b/package.json index 599f41470..f1e6933cb 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "scripts": { "build": "bun run scripts/build.js", "build:browser": "node scripts/build-browser-detector.js", + "build:extension": "node scripts/build-extension.js", "clean": "rm -rf dist build", "rebuild": "bun run clean && bun run build", "dev": "bun run server/index.js", diff --git a/scripts/build-extension.js b/scripts/build-extension.js new file mode 100644 index 000000000..589b76d31 --- /dev/null +++ b/scripts/build-extension.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +/** + * Builds the Chrome DevTools extension. + * + * 1. Generates the extension variant of the browser detector + * 2. Extracts antipatterns.json for the panel UI + * 3. Optionally packages as a .zip for Chrome Web Store + * + * Run: node scripts/build-extension.js + * node scripts/build-extension.js --zip + */ + +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 EXT_DIR = path.join(ROOT, 'extension'); + +const SOURCE = path.join(ROOT, 'src/detect-antipatterns.mjs'); +const DETECTOR_OUTPUT = path.join(EXT_DIR, 'detector/detect.js'); +const AP_OUTPUT = path.join(EXT_DIR, 'detector/antipatterns.json'); + +let code = fs.readFileSync(SOURCE, 'utf-8'); + +// --- 1. Build detector --- + +// Strip shebang +code = code.replace(/^#!.*\n/, ''); +// Strip sections between @browser-strip-start / @browser-strip-end markers +code = code.replace(/^\/\/ @browser-strip-start\n[\s\S]*?^\/\/ @browser-strip-end\n?/gm, ''); +// Set IS_BROWSER = true (dead-code eliminates Node paths) +code = code.replace(/^const IS_BROWSER = .*$/m, 'const IS_BROWSER = true;'); + +const output = `/** + * Anti-Pattern Browser Detector for Impeccable (Extension Variant) + * Copyright (c) 2026 Paul Bakaus + * SPDX-License-Identifier: Apache-2.0 + * + * GENERATED -- do not edit. Source: detect-antipatterns.mjs + * Rebuild: node scripts/build-extension.js + */ +(function () { +if (typeof window === 'undefined') return; +${code} +})(); +`; + +fs.mkdirSync(path.dirname(DETECTOR_OUTPUT), { recursive: true }); +fs.writeFileSync(DETECTOR_OUTPUT, output); +console.log(`Generated ${path.relative(ROOT, DETECTOR_OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`); + +// --- 2. Extract antipatterns.json --- + +const rawSource = fs.readFileSync(SOURCE, 'utf-8'); +const apMatch = rawSource.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/); +if (apMatch) { + // Convert JS object literals to JSON + const antipatterns = new Function(`return [${apMatch[1]}]`)(); + const apJson = antipatterns.map(({ id, name, category }) => ({ id, name, category: category || 'quality' })); + fs.writeFileSync(AP_OUTPUT, JSON.stringify(apJson, null, 2) + '\n'); + console.log(`Generated ${path.relative(ROOT, AP_OUTPUT)} (${antipatterns.length} rules)`); +} + +// --- 3. Zip packaging --- + +if (process.argv.includes('--zip')) { + const archiver = (await import('archiver')).default; + const zipPath = path.join(ROOT, 'dist/impeccable-extension.zip'); + fs.mkdirSync(path.dirname(zipPath), { recursive: true }); + + const zipStream = fs.createWriteStream(zipPath); + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.pipe(zipStream); + archive.directory(EXT_DIR, false); + + await archive.finalize(); + const size = fs.statSync(zipPath).size; + console.log(`Packaged ${path.relative(ROOT, zipPath)} (${(size / 1024).toFixed(1)} KB)`); +} diff --git a/scripts/generate-extension-icons.js b/scripts/generate-extension-icons.js new file mode 100644 index 000000000..8c5f41660 --- /dev/null +++ b/scripts/generate-extension-icons.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +/** + * Generates PNG extension icons from SVG using Puppeteer. + * + * Run: node scripts/generate-extension-icons.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 ICONS_DIR = path.join(ROOT, 'extension/icons'); + +const SIZES = [16, 32, 48, 128]; + +const svgContent = fs.readFileSync(path.join(ICONS_DIR, 'icon.svg'), 'utf-8'); + +const browser = await puppeteer.launch({ headless: true }); +const page = await browser.newPage(); + +for (const size of SIZES) { + await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 }); + await page.setContent(` + + + + ${svgContent.replace('viewBox="0 0 128 128"', `viewBox="0 0 128 128" width="${size}" height="${size}"`)} + + `); + await page.screenshot({ path: path.join(ICONS_DIR, `icon-${size}.png`), omitBackground: true }); + console.log(`Generated icon-${size}.png`); +} + +await browser.close(); diff --git a/src/detect-antipatterns-browser.js b/src/detect-antipatterns-browser.js index efce3ebed..d243cab92 100644 --- a/src/detect-antipatterns-browser.js +++ b/src/detect-antipatterns-browser.js @@ -60,146 +60,173 @@ const GENERIC_FONTS = new Set([ ]); const ANTIPATTERNS = [ + // ── AI slop: tells that something was AI-generated ── { id: 'side-tab', + category: 'slop', name: 'Side-tab accent border', description: 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', }, { id: 'border-accent-on-rounded', + category: 'slop', name: 'Border accent on rounded element', description: 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', }, { id: 'overused-font', + category: 'slop', name: 'Overused font', description: 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', }, { id: 'single-font', + category: 'slop', name: 'Single font for everything', description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', }, { id: 'flat-type-hierarchy', + category: 'slop', name: 'Flat type hierarchy', description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', }, - { - id: 'pure-black-white', - name: 'Pure black background', - description: - 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.', - }, - { - id: 'gray-on-color', - name: 'Gray text on colored background', - description: - 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', - }, - { - id: 'low-contrast', - name: 'Low contrast text', - description: - 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', - }, { id: 'gradient-text', + category: 'slop', name: 'Gradient text', description: 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', }, { id: 'ai-color-palette', + category: 'slop', name: 'AI color palette', description: 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', }, { id: 'nested-cards', + category: 'slop', name: 'Nested cards', description: 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', }, { id: 'monotonous-spacing', + category: 'slop', name: 'Monotonous spacing', description: 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', }, { id: 'everything-centered', + category: 'slop', name: 'Everything centered', description: 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.', }, { id: 'bounce-easing', + category: 'slop', name: 'Bounce or elastic easing', description: 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', }, + { + id: 'dark-glow', + category: 'slop', + name: 'Dark mode with glowing accents', + description: + 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', + }, + + // ── Quality: general design and accessibility issues ── + { + id: 'pure-black-white', + category: 'quality', + name: 'Pure black background', + description: + 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.', + }, + { + id: 'gray-on-color', + category: 'quality', + name: 'Gray text on colored background', + description: + 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', + }, + { + id: 'low-contrast', + category: 'quality', + name: 'Low contrast text', + description: + 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', + }, { id: 'layout-transition', + category: 'quality', name: 'Layout property animation', description: 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', }, - { - id: 'dark-glow', - name: 'Dark mode with glowing accents', - description: - 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', - }, { id: 'line-length', + category: 'quality', name: 'Line length too long', description: 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', }, { id: 'cramped-padding', + category: 'quality', name: 'Cramped padding', description: 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.', }, { id: 'tight-leading', + category: 'quality', name: 'Tight line height', description: 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', }, { id: 'skipped-heading', + category: 'quality', name: 'Skipped heading level', description: 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', }, { id: 'justified-text', + category: 'quality', name: 'Justified text', description: 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', }, { id: 'tiny-text', + category: 'quality', name: 'Tiny body text', description: 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', }, { id: 'all-caps-body', + category: 'quality', name: 'All-caps body text', description: 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', }, { id: 'wide-tracking', + category: 'quality', name: 'Wide letter spacing on body text', description: 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', @@ -1209,43 +1236,43 @@ function checkPageLayout(doc, win) { // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── if (IS_BROWSER) { - const LABEL_BG = 'oklch(55% 0.25 350)'; - const OUTLINE_COLOR = 'oklch(60% 0.25 350)'; + const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true'; + + const BRAND_COLOR = 'oklch(55% 0.25 350)'; + const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)'; + const LABEL_BG = BRAND_COLOR; + const OUTLINE_COLOR = BRAND_COLOR; // Inject hover styles via CSS (more reliable than JS event listeners) const styleEl = document.createElement('style'); styleEl.textContent = ` @keyframes impeccable-reveal { - from { opacity: 0; outline-color: transparent; } - to { opacity: 1; outline-color: ${OUTLINE_COLOR}; } + from { opacity: 0; } + to { opacity: 1; } } .impeccable-overlay:not(.impeccable-banner) { pointer-events: none; outline: 2px solid ${OUTLINE_COLOR}; border-radius: 4px; - transition: outline-color 0.3s ease; + transition: outline-color 0.15s ease; animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; animation-play-state: paused; + border-top-left-radius: 0; } .impeccable-overlay.impeccable-visible { animation-play-state: running; } .impeccable-overlay.impeccable-hover { - outline-color: rgba(0,0,0,0.85); + outline-color: ${BRAND_COLOR_HOVER}; z-index: 100001 !important; } - .impeccable-label-name, - .impeccable-label-detail { - transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + .impeccable-label { + transition: background 0.15s ease; } - .impeccable-label-detail { - position: absolute; top: 100%; left: 0; + .impeccable-overlay.impeccable-hover .impeccable-label { + background: ${BRAND_COLOR_HOVER}; } - .impeccable-overlay.impeccable-hover .impeccable-label-name, - .impeccable-overlay.impeccable-hover .impeccable-label-detail { - transform: translateY(-100%); - } - .impeccable-hidden .impeccable-overlay:not(.impeccable-banner) { + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { display: none !important; } `; @@ -1253,8 +1280,10 @@ if (IS_BROWSER) { const overlays = []; const TYPE_LABELS = {}; + const RULE_CATEGORY = {}; for (const ap of ANTIPATTERNS) { TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26); + RULE_CATEGORY[ap.id] = ap.category || 'quality'; } function isInFixedContext(el) { @@ -1312,7 +1341,10 @@ if (IS_BROWSER) { if (!overlay._revealed) { overlay._revealed = true; overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`; - requestAnimationFrame(() => overlay.classList.add('impeccable-visible')); + requestAnimationFrame(() => { + overlay.classList.add('impeccable-visible'); + if (overlay._checkLabel) overlay._checkLabel(); + }); } } else { overlay.style.display = 'none'; @@ -1334,6 +1366,8 @@ if (IS_BROWSER) { }); const highlight = function(el, findings) { + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); + const fixed = isInFixedContext(el); const rect = el.getBoundingClientRect(); const outline = document.createElement('div'); @@ -1348,33 +1382,85 @@ if (IS_BROWSER) { zIndex: '99999', boxSizing: 'border-box', }); - const typeText = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', '); - const detailText = findings.map(f => f.detail || f.snippet).join(' | '); + // Build per-finding label entries: ✦ prefix for slop + const entries = findings.map(f => { + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; + return { name: prefix + name, detail: f.detail || f.snippet }; + }); + const allText = entries.map(e => e.name).join(', '); const label = document.createElement('div'); label.className = 'impeccable-label'; Object.assign(label.style, { - position: 'absolute', top: '-22px', left: '0', - clipPath: 'inset(0 -999px)', + position: 'absolute', bottom: '100%', left: '-2px', + display: 'flex', alignItems: 'center', + whiteSpace: 'nowrap', + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', + color: 'white', lineHeight: '14px', + background: LABEL_BG, + fontFamily: 'system-ui, sans-serif', + borderRadius: '4px 4px 0 0', }); - const rowBase = { - padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap', - fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', - color: 'white', lineHeight: '16px', - }; + const textSpan = document.createElement('span'); + textSpan.style.padding = '3px 8px'; + textSpan.textContent = allText; + label.appendChild(textSpan); - const nameRow = document.createElement('div'); - nameRow.className = 'impeccable-label-name'; - nameRow.textContent = typeText; - Object.assign(nameRow.style, { ...rowBase, background: LABEL_BG, fontFamily: 'system-ui, sans-serif' }); - label.appendChild(nameRow); + // State for cycling mode + let cycleMode = false; + let cycleIndex = 0; + let isHovered = false; + let prevBtn, nextBtn; - const detailRow = document.createElement('div'); - detailRow.className = 'impeccable-label-detail'; - detailRow.textContent = detailText; - Object.assign(detailRow.style, { ...rowBase, background: 'rgba(0,0,0,0.85)', fontFamily: 'ui-monospace, monospace', fontWeight: '400' }); - label.appendChild(detailRow); + function updateCycleText() { + const e = entries[cycleIndex]; + textSpan.textContent = isHovered ? e.detail : e.name; + } + + function enableCycleMode() { + if (cycleMode || entries.length < 2) return; + cycleMode = true; + + const btnStyle = { + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', + pointerEvents: 'auto', + }; + + const navGroup = document.createElement('span'); + Object.assign(navGroup.style, { + display: 'inline-flex', alignItems: 'center', flexShrink: '0', + }); + + prevBtn = document.createElement('button'); + prevBtn.textContent = '\u2039'; + Object.assign(prevBtn.style, btnStyle); + prevBtn.style.paddingLeft = '6px'; + prevBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; + updateCycleText(); + }); + + nextBtn = document.createElement('button'); + nextBtn.textContent = '\u203A'; + Object.assign(nextBtn.style, btnStyle); + nextBtn.style.paddingRight = '2px'; + nextBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex + 1) % entries.length; + updateCycleText(); + }); + + navGroup.appendChild(prevBtn); + navGroup.appendChild(nextBtn); + label.insertBefore(navGroup, textSpan); + textSpan.style.padding = '3px 8px 3px 4px'; + updateCycleText(); + } outline.appendChild(label); @@ -1384,9 +1470,36 @@ if (IS_BROWSER) { el._impeccableOverlay = outline; visibilityObserver.observe(el); - // Drive hover state from the target element so pointer events pass through - el.addEventListener('mouseenter', () => outline.classList.add('impeccable-hover')); - el.addEventListener('mouseleave', () => outline.classList.remove('impeccable-hover')); + // After first paint, check label width vs outline + outline._checkLabel = () => { + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { + enableCycleMode(); + } + }; + + // Hover: show detail text, darken + el.addEventListener('mouseenter', () => { + isHovered = true; + outline.classList.add('impeccable-hover'); + outline.style.outlineColor = BRAND_COLOR_HOVER; + label.style.background = BRAND_COLOR_HOVER; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = entries.map(e => e.detail).join(' | '); + } + }); + el.addEventListener('mouseleave', () => { + isHovered = false; + outline.classList.remove('impeccable-hover'); + outline.style.outlineColor = ''; + label.style.background = LABEL_BG; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = allText; + } + }); document.body.appendChild(outline); overlays.push(outline); @@ -1418,8 +1531,9 @@ if (IS_BROWSER) { scrollbarWidth: 'none', }); for (const f of findings) { + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; const tag = document.createElement('span'); - tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; Object.assign(tag.style, { background: 'rgba(255,255,255,0.15)', padding: '2px 8px', borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', @@ -1429,47 +1543,89 @@ if (IS_BROWSER) { } banner.appendChild(scrollArea); - // Controls area (always visible on the right) - const controls = document.createElement('div'); - Object.assign(controls.style, { - display: 'flex', alignItems: 'center', gap: '2px', - padding: '0 8px', flexShrink: '0', - }); + // Controls area (only in standalone mode, not extension) + if (!EXTENSION_MODE) { + const controls = document.createElement('div'); + Object.assign(controls.style, { + display: 'flex', alignItems: 'center', gap: '2px', + padding: '0 8px', flexShrink: '0', + }); - // Toggle visibility button - const toggle = document.createElement('button'); - toggle.textContent = '\u25C9'; // circle with dot (visible state) - toggle.title = 'Toggle overlay visibility'; - Object.assign(toggle.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', - opacity: '0.85', transition: 'opacity 0.15s', - }); - let overlaysVisible = true; - toggle.addEventListener('click', () => { - overlaysVisible = !overlaysVisible; - document.body.classList.toggle('impeccable-hidden', !overlaysVisible); - toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle - toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; - }); - controls.appendChild(toggle); + // Toggle visibility button + const toggle = document.createElement('button'); + toggle.textContent = '\u25C9'; // circle with dot (visible state) + toggle.title = 'Toggle overlay visibility'; + Object.assign(toggle.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', + opacity: '0.85', transition: 'opacity 0.15s', + }); + let overlaysVisible = true; + toggle.addEventListener('click', () => { + overlaysVisible = !overlaysVisible; + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; + }); + controls.appendChild(toggle); - // Close button - const close = document.createElement('button'); - close.textContent = '\u00d7'; - close.title = 'Dismiss banner'; - Object.assign(close.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', - }); - close.addEventListener('click', () => banner.remove()); - controls.appendChild(close); + // Close button + const close = document.createElement('button'); + close.textContent = '\u00d7'; + close.title = 'Dismiss banner'; + Object.assign(close.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', + }); + close.addEventListener('click', () => banner.remove()); + controls.appendChild(close); - banner.appendChild(controls); + banner.appendChild(controls); + } document.body.appendChild(banner); overlays.push(banner); }; + function generateSelector(el) { + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + if (el.id) return '#' + CSS.escape(el.id); + const parts = []; + let current = el; + while (current && current !== document.body) { + let sel = current.tagName.toLowerCase(); + if (current.id) { parts.unshift('#' + CSS.escape(current.id)); break; } + const siblings = current.parentElement?.children; + if (siblings && siblings.length > 1) { + const index = [...siblings].indexOf(current) + 1; + sel += ':nth-child(' + index + ')'; + } + parts.unshift(sel); + current = current.parentElement; + } + return parts.join(' > '); + } + + function serializeFindings(allFindings) { + return allFindings.map(({ el, findings }) => ({ + selector: generateSelector(el), + tagName: el.tagName?.toLowerCase() || 'unknown', + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) + ? el.getBoundingClientRect().toJSON() : null, + isPageLevel: el === document.body || el === document.documentElement, + findings: findings.map(f => { + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + return { + type: f.type || f.id, + category: ap ? ap.category : 'quality', + detail: f.detail || f.snippet, + name: ap ? ap.name : (f.type || f.id), + description: ap ? ap.description : '', + }; + }), + })); + } + const printSummary = function(allFindings) { if (allFindings.length === 0) { console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); @@ -1493,6 +1649,8 @@ if (IS_BROWSER) { overlays.length = 0; visibilityObserver.disconnect(); 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') || @@ -1511,7 +1669,7 @@ if (IS_BROWSER) { ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ]; + ].filter(f => _ruleOk(f.type)); if (findings.length > 0) { highlight(el, findings); @@ -1521,13 +1679,13 @@ if (IS_BROWSER) { const pageLevelFindings = []; - const typoFindings = checkTypography(); + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); if (typoFindings.length > 0) { pageLevelFindings.push(...typoFindings); allFindings.push({ el: document.body, findings: typoFindings }); } - const layoutFindings = checkLayout(); + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); for (const f of layoutFindings) { const el = f.el || document.body; delete f.el; @@ -1546,7 +1704,7 @@ if (IS_BROWSER) { } // Page-level quality checks (headings, etc.) - const qualityFindings = checkPageQualityDOM(); + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { pageLevelFindings.push(...qualityFindings); allFindings.push({ el: document.body, findings: qualityFindings }); @@ -1555,7 +1713,7 @@ if (IS_BROWSER) { // Regex-on-HTML checks (shared with Node) const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML); if (htmlPatternFindings.length > 0) { - const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })); + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); pageLevelFindings.push(...mapped); allFindings.push({ el: document.body, findings: mapped }); } @@ -1564,14 +1722,48 @@ if (IS_BROWSER) { showPageBanner(pageLevelFindings); } - printSummary(allFindings); + if (!EXTENSION_MODE) printSummary(allFindings); + + // In extension mode, post serialized results for the DevTools panel + if (EXTENSION_MODE) { + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + }, '*'); + } + return allFindings; }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100)); + if (EXTENSION_MODE) { + // Extension mode: listen for commands, don't auto-scan + window.addEventListener('message', (e) => { + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; + if (e.data.action === 'scan') { + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; + scan(); + } + if (e.data.action === 'toggle-overlays') { + const visible = !document.body.classList.contains('impeccable-hidden'); + document.body.classList.toggle('impeccable-hidden', visible); + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); + } + if (e.data.action === 'remove') { + for (const o of overlays) o.remove(); + overlays.length = 0; + visibilityObserver.disconnect(); + styleEl.remove(); + document.body.classList.remove('impeccable-hidden'); + } + }); + window.postMessage({ source: 'impeccable-ready' }, '*'); } else { - setTimeout(scan, 100); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100)); + } else { + setTimeout(scan, 100); + } } window.impeccableScan = scan; diff --git a/src/detect-antipatterns.mjs b/src/detect-antipatterns.mjs index 61b6c84ce..fa61afbc2 100644 --- a/src/detect-antipatterns.mjs +++ b/src/detect-antipatterns.mjs @@ -55,146 +55,173 @@ const GENERIC_FONTS = new Set([ ]); const ANTIPATTERNS = [ + // ── AI slop: tells that something was AI-generated ── { id: 'side-tab', + category: 'slop', name: 'Side-tab accent border', description: 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', }, { id: 'border-accent-on-rounded', + category: 'slop', name: 'Border accent on rounded element', description: 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', }, { id: 'overused-font', + category: 'slop', name: 'Overused font', description: 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', }, { id: 'single-font', + category: 'slop', name: 'Single font for everything', description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', }, { id: 'flat-type-hierarchy', + category: 'slop', name: 'Flat type hierarchy', description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', }, - { - id: 'pure-black-white', - name: 'Pure black background', - description: - 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.', - }, - { - id: 'gray-on-color', - name: 'Gray text on colored background', - description: - 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', - }, - { - id: 'low-contrast', - name: 'Low contrast text', - description: - 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', - }, { id: 'gradient-text', + category: 'slop', name: 'Gradient text', description: 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', }, { id: 'ai-color-palette', + category: 'slop', name: 'AI color palette', description: 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', }, { id: 'nested-cards', + category: 'slop', name: 'Nested cards', description: 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', }, { id: 'monotonous-spacing', + category: 'slop', name: 'Monotonous spacing', description: 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', }, { id: 'everything-centered', + category: 'slop', name: 'Everything centered', description: 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.', }, { id: 'bounce-easing', + category: 'slop', name: 'Bounce or elastic easing', description: 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', }, + { + id: 'dark-glow', + category: 'slop', + name: 'Dark mode with glowing accents', + description: + 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', + }, + + // ── Quality: general design and accessibility issues ── + { + id: 'pure-black-white', + category: 'quality', + name: 'Pure black background', + description: + 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.', + }, + { + id: 'gray-on-color', + category: 'quality', + name: 'Gray text on colored background', + description: + 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', + }, + { + id: 'low-contrast', + category: 'quality', + name: 'Low contrast text', + description: + 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', + }, { id: 'layout-transition', + category: 'quality', name: 'Layout property animation', description: 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', }, - { - id: 'dark-glow', - name: 'Dark mode with glowing accents', - description: - 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', - }, { id: 'line-length', + category: 'quality', name: 'Line length too long', description: 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', }, { id: 'cramped-padding', + category: 'quality', name: 'Cramped padding', description: 'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.', }, { id: 'tight-leading', + category: 'quality', name: 'Tight line height', description: 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', }, { id: 'skipped-heading', + category: 'quality', name: 'Skipped heading level', description: 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', }, { id: 'justified-text', + category: 'quality', name: 'Justified text', description: 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', }, { id: 'tiny-text', + category: 'quality', name: 'Tiny body text', description: 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', }, { id: 'all-caps-body', + category: 'quality', name: 'All-caps body text', description: 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', }, { id: 'wide-tracking', + category: 'quality', name: 'Wide letter spacing on body text', description: 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', @@ -1204,43 +1231,43 @@ function checkPageLayout(doc, win) { // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── if (IS_BROWSER) { - const LABEL_BG = 'oklch(55% 0.25 350)'; - const OUTLINE_COLOR = 'oklch(60% 0.25 350)'; + const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true'; + + const BRAND_COLOR = 'oklch(55% 0.25 350)'; + const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)'; + const LABEL_BG = BRAND_COLOR; + const OUTLINE_COLOR = BRAND_COLOR; // Inject hover styles via CSS (more reliable than JS event listeners) const styleEl = document.createElement('style'); styleEl.textContent = ` @keyframes impeccable-reveal { - from { opacity: 0; outline-color: transparent; } - to { opacity: 1; outline-color: ${OUTLINE_COLOR}; } + from { opacity: 0; } + to { opacity: 1; } } .impeccable-overlay:not(.impeccable-banner) { pointer-events: none; outline: 2px solid ${OUTLINE_COLOR}; border-radius: 4px; - transition: outline-color 0.3s ease; + transition: outline-color 0.15s ease; animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; animation-play-state: paused; + border-top-left-radius: 0; } .impeccable-overlay.impeccable-visible { animation-play-state: running; } .impeccable-overlay.impeccable-hover { - outline-color: rgba(0,0,0,0.85); + outline-color: ${BRAND_COLOR_HOVER}; z-index: 100001 !important; } - .impeccable-label-name, - .impeccable-label-detail { - transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + .impeccable-label { + transition: background 0.15s ease; } - .impeccable-label-detail { - position: absolute; top: 100%; left: 0; + .impeccable-overlay.impeccable-hover .impeccable-label { + background: ${BRAND_COLOR_HOVER}; } - .impeccable-overlay.impeccable-hover .impeccable-label-name, - .impeccable-overlay.impeccable-hover .impeccable-label-detail { - transform: translateY(-100%); - } - .impeccable-hidden .impeccable-overlay:not(.impeccable-banner) { + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { display: none !important; } `; @@ -1248,8 +1275,10 @@ if (IS_BROWSER) { const overlays = []; const TYPE_LABELS = {}; + const RULE_CATEGORY = {}; for (const ap of ANTIPATTERNS) { TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26); + RULE_CATEGORY[ap.id] = ap.category || 'quality'; } function isInFixedContext(el) { @@ -1307,7 +1336,10 @@ if (IS_BROWSER) { if (!overlay._revealed) { overlay._revealed = true; overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`; - requestAnimationFrame(() => overlay.classList.add('impeccable-visible')); + requestAnimationFrame(() => { + overlay.classList.add('impeccable-visible'); + if (overlay._checkLabel) overlay._checkLabel(); + }); } } else { overlay.style.display = 'none'; @@ -1329,6 +1361,8 @@ if (IS_BROWSER) { }); const highlight = function(el, findings) { + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); + const fixed = isInFixedContext(el); const rect = el.getBoundingClientRect(); const outline = document.createElement('div'); @@ -1343,33 +1377,85 @@ if (IS_BROWSER) { zIndex: '99999', boxSizing: 'border-box', }); - const typeText = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', '); - const detailText = findings.map(f => f.detail || f.snippet).join(' | '); + // Build per-finding label entries: ✦ prefix for slop + const entries = findings.map(f => { + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; + return { name: prefix + name, detail: f.detail || f.snippet }; + }); + const allText = entries.map(e => e.name).join(', '); const label = document.createElement('div'); label.className = 'impeccable-label'; Object.assign(label.style, { - position: 'absolute', top: '-22px', left: '0', - clipPath: 'inset(0 -999px)', + position: 'absolute', bottom: '100%', left: '-2px', + display: 'flex', alignItems: 'center', + whiteSpace: 'nowrap', + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', + color: 'white', lineHeight: '14px', + background: LABEL_BG, + fontFamily: 'system-ui, sans-serif', + borderRadius: '4px 4px 0 0', }); - const rowBase = { - padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap', - fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', - color: 'white', lineHeight: '16px', - }; + const textSpan = document.createElement('span'); + textSpan.style.padding = '3px 8px'; + textSpan.textContent = allText; + label.appendChild(textSpan); - const nameRow = document.createElement('div'); - nameRow.className = 'impeccable-label-name'; - nameRow.textContent = typeText; - Object.assign(nameRow.style, { ...rowBase, background: LABEL_BG, fontFamily: 'system-ui, sans-serif' }); - label.appendChild(nameRow); + // State for cycling mode + let cycleMode = false; + let cycleIndex = 0; + let isHovered = false; + let prevBtn, nextBtn; - const detailRow = document.createElement('div'); - detailRow.className = 'impeccable-label-detail'; - detailRow.textContent = detailText; - Object.assign(detailRow.style, { ...rowBase, background: 'rgba(0,0,0,0.85)', fontFamily: 'ui-monospace, monospace', fontWeight: '400' }); - label.appendChild(detailRow); + function updateCycleText() { + const e = entries[cycleIndex]; + textSpan.textContent = isHovered ? e.detail : e.name; + } + + function enableCycleMode() { + if (cycleMode || entries.length < 2) return; + cycleMode = true; + + const btnStyle = { + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', + pointerEvents: 'auto', + }; + + const navGroup = document.createElement('span'); + Object.assign(navGroup.style, { + display: 'inline-flex', alignItems: 'center', flexShrink: '0', + }); + + prevBtn = document.createElement('button'); + prevBtn.textContent = '\u2039'; + Object.assign(prevBtn.style, btnStyle); + prevBtn.style.paddingLeft = '6px'; + prevBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; + updateCycleText(); + }); + + nextBtn = document.createElement('button'); + nextBtn.textContent = '\u203A'; + Object.assign(nextBtn.style, btnStyle); + nextBtn.style.paddingRight = '2px'; + nextBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex + 1) % entries.length; + updateCycleText(); + }); + + navGroup.appendChild(prevBtn); + navGroup.appendChild(nextBtn); + label.insertBefore(navGroup, textSpan); + textSpan.style.padding = '3px 8px 3px 4px'; + updateCycleText(); + } outline.appendChild(label); @@ -1379,9 +1465,36 @@ if (IS_BROWSER) { el._impeccableOverlay = outline; visibilityObserver.observe(el); - // Drive hover state from the target element so pointer events pass through - el.addEventListener('mouseenter', () => outline.classList.add('impeccable-hover')); - el.addEventListener('mouseleave', () => outline.classList.remove('impeccable-hover')); + // After first paint, check label width vs outline + outline._checkLabel = () => { + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { + enableCycleMode(); + } + }; + + // Hover: show detail text, darken + el.addEventListener('mouseenter', () => { + isHovered = true; + outline.classList.add('impeccable-hover'); + outline.style.outlineColor = BRAND_COLOR_HOVER; + label.style.background = BRAND_COLOR_HOVER; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = entries.map(e => e.detail).join(' | '); + } + }); + el.addEventListener('mouseleave', () => { + isHovered = false; + outline.classList.remove('impeccable-hover'); + outline.style.outlineColor = ''; + label.style.background = LABEL_BG; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = allText; + } + }); document.body.appendChild(outline); overlays.push(outline); @@ -1413,8 +1526,9 @@ if (IS_BROWSER) { scrollbarWidth: 'none', }); for (const f of findings) { + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; const tag = document.createElement('span'); - tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; Object.assign(tag.style, { background: 'rgba(255,255,255,0.15)', padding: '2px 8px', borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', @@ -1424,47 +1538,89 @@ if (IS_BROWSER) { } banner.appendChild(scrollArea); - // Controls area (always visible on the right) - const controls = document.createElement('div'); - Object.assign(controls.style, { - display: 'flex', alignItems: 'center', gap: '2px', - padding: '0 8px', flexShrink: '0', - }); + // Controls area (only in standalone mode, not extension) + if (!EXTENSION_MODE) { + const controls = document.createElement('div'); + Object.assign(controls.style, { + display: 'flex', alignItems: 'center', gap: '2px', + padding: '0 8px', flexShrink: '0', + }); - // Toggle visibility button - const toggle = document.createElement('button'); - toggle.textContent = '\u25C9'; // circle with dot (visible state) - toggle.title = 'Toggle overlay visibility'; - Object.assign(toggle.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', - opacity: '0.85', transition: 'opacity 0.15s', - }); - let overlaysVisible = true; - toggle.addEventListener('click', () => { - overlaysVisible = !overlaysVisible; - document.body.classList.toggle('impeccable-hidden', !overlaysVisible); - toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle - toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; - }); - controls.appendChild(toggle); + // Toggle visibility button + const toggle = document.createElement('button'); + toggle.textContent = '\u25C9'; // circle with dot (visible state) + toggle.title = 'Toggle overlay visibility'; + Object.assign(toggle.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', + opacity: '0.85', transition: 'opacity 0.15s', + }); + let overlaysVisible = true; + toggle.addEventListener('click', () => { + overlaysVisible = !overlaysVisible; + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; + }); + controls.appendChild(toggle); - // Close button - const close = document.createElement('button'); - close.textContent = '\u00d7'; - close.title = 'Dismiss banner'; - Object.assign(close.style, { - background: 'none', border: 'none', - color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', - }); - close.addEventListener('click', () => banner.remove()); - controls.appendChild(close); + // Close button + const close = document.createElement('button'); + close.textContent = '\u00d7'; + close.title = 'Dismiss banner'; + Object.assign(close.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', + }); + close.addEventListener('click', () => banner.remove()); + controls.appendChild(close); - banner.appendChild(controls); + banner.appendChild(controls); + } document.body.appendChild(banner); overlays.push(banner); }; + function generateSelector(el) { + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + if (el.id) return '#' + CSS.escape(el.id); + const parts = []; + let current = el; + while (current && current !== document.body) { + let sel = current.tagName.toLowerCase(); + if (current.id) { parts.unshift('#' + CSS.escape(current.id)); break; } + const siblings = current.parentElement?.children; + if (siblings && siblings.length > 1) { + const index = [...siblings].indexOf(current) + 1; + sel += ':nth-child(' + index + ')'; + } + parts.unshift(sel); + current = current.parentElement; + } + return parts.join(' > '); + } + + function serializeFindings(allFindings) { + return allFindings.map(({ el, findings }) => ({ + selector: generateSelector(el), + tagName: el.tagName?.toLowerCase() || 'unknown', + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) + ? el.getBoundingClientRect().toJSON() : null, + isPageLevel: el === document.body || el === document.documentElement, + findings: findings.map(f => { + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + return { + type: f.type || f.id, + category: ap ? ap.category : 'quality', + detail: f.detail || f.snippet, + name: ap ? ap.name : (f.type || f.id), + description: ap ? ap.description : '', + }; + }), + })); + } + const printSummary = function(allFindings) { if (allFindings.length === 0) { console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); @@ -1488,6 +1644,8 @@ if (IS_BROWSER) { overlays.length = 0; visibilityObserver.disconnect(); 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') || @@ -1506,7 +1664,7 @@ if (IS_BROWSER) { ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ]; + ].filter(f => _ruleOk(f.type)); if (findings.length > 0) { highlight(el, findings); @@ -1516,13 +1674,13 @@ if (IS_BROWSER) { const pageLevelFindings = []; - const typoFindings = checkTypography(); + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); if (typoFindings.length > 0) { pageLevelFindings.push(...typoFindings); allFindings.push({ el: document.body, findings: typoFindings }); } - const layoutFindings = checkLayout(); + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); for (const f of layoutFindings) { const el = f.el || document.body; delete f.el; @@ -1541,7 +1699,7 @@ if (IS_BROWSER) { } // Page-level quality checks (headings, etc.) - const qualityFindings = checkPageQualityDOM(); + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { pageLevelFindings.push(...qualityFindings); allFindings.push({ el: document.body, findings: qualityFindings }); @@ -1550,7 +1708,7 @@ if (IS_BROWSER) { // Regex-on-HTML checks (shared with Node) const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML); if (htmlPatternFindings.length > 0) { - const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })); + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); pageLevelFindings.push(...mapped); allFindings.push({ el: document.body, findings: mapped }); } @@ -1559,14 +1717,48 @@ if (IS_BROWSER) { showPageBanner(pageLevelFindings); } - printSummary(allFindings); + if (!EXTENSION_MODE) printSummary(allFindings); + + // In extension mode, post serialized results for the DevTools panel + if (EXTENSION_MODE) { + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + }, '*'); + } + return allFindings; }; - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100)); + if (EXTENSION_MODE) { + // Extension mode: listen for commands, don't auto-scan + window.addEventListener('message', (e) => { + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; + if (e.data.action === 'scan') { + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; + scan(); + } + if (e.data.action === 'toggle-overlays') { + const visible = !document.body.classList.contains('impeccable-hidden'); + document.body.classList.toggle('impeccable-hidden', visible); + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); + } + if (e.data.action === 'remove') { + for (const o of overlays) o.remove(); + overlays.length = 0; + visibilityObserver.disconnect(); + styleEl.remove(); + document.body.classList.remove('impeccable-hidden'); + } + }); + window.postMessage({ source: 'impeccable-ready' }, '*'); } else { - setTimeout(scan, 100); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100)); + } else { + setTimeout(scan, 100); + } } window.impeccableScan = scan;