/** * 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; // 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'); let overlaysVisible = true; let allAntipatterns = []; let disabledRules = []; let currentFindings = []; // 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: [], 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 cat = ap.category || 'quality'; (categories[cat] || categories.quality).items.push(ap); } for (const [, group] of Object.entries(categories)) { if (!group.items.length) continue; const header = document.createElement('div'); header.className = 'settings-header'; header.textContent = group.label; settingsList.appendChild(header); 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); } } 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 (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) { overlaysVisible = msg.overlaysVisible; updateToggleButton(); } } if (msg.action === 'overlays-toggled') { overlaysVisible = msg.visible; updateToggleButton(); } 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(); postToPort({ action: 'scan' }); }); btnToggle.addEventListener('click', () => { postToPort({ 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.title = overlaysVisible ? 'Hide overlays' : 'Show overlays'; btnToggle.classList.toggle('inactive', !overlaysVisible); } function showScanning() { container.innerHTML = `
Scanning page...
`; } 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); 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, isHidden: item.isHidden, 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' + (item.isHidden ? ' is-hidden' : ''); const tag = item.isPageLevel ? 'page' : item.isHidden ? 'hidden' : ''; itemEl.innerHTML = ` ${tag}
${escapeHtml(item.selector)}
${escapeHtml(item.detail)} ${escapeHtml(group.description)}`; 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)); } 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();