Polish Chrome extension for Web Store submission

Refactors the extension for on-demand injection (no static content_scripts
entry — content script and detector are loaded only when the user actively
opens the Impeccable panel, sidebar pane, or popup). Adds a new "Auto-scan"
preference (default: scan when the Impeccable panel opens, opt-in: scan
when DevTools opens) plus configurable line length (strict/lax) and
highlight blur on/off settings. Adds an Elements panel sidebar that shows
findings for the currently selected element.

Includes substantial overlay UX work: page-pixel-perfect spotlight mask
via clip-path, refined hover/dim states, instant transitions for snappier
feel, copy buttons for findings, hover-from-panel highlighting, and a
brand-aware exception list so the font check no longer flags Roboto on
Google's own properties.

Robustness fixes for the MV3 service worker lifecycle: heartbeat keepalive
plus auto-reconnecting ports across panel/sidebar/devtools so transient
SW restarts don't break the panel UI, and immediate teardown on DevTools
close (replacing an unreliable setTimeout-based defer that didn't survive
SW termination).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-06 21:06:09 -07:00
co-authored by Claude Opus 4.6
parent e961d56252
commit 13b2f763d9
16 changed files with 1465 additions and 178 deletions
+63
View File
@@ -0,0 +1,63 @@
# Chrome Web Store Listing
## Name
Impeccable
## Short description (132 chars max)
Detect AI slop and design anti-patterns in any web page. Open DevTools and see what needs fixing.
## Detailed description
Impeccable detects 24 common UI anti-patterns directly in your browser. Open DevTools on any page and overlays instantly highlight issues, from AI-generated design tells to accessibility and quality problems.
WHAT IT DETECTS
AI slop (design tells that scream "AI made this"):
- Side-tab accent borders
- Gradient text on headings
- Purple/violet AI color palettes
- Nested cards, monotonous spacing
- Bounce/elastic easing
- Dark mode with glowing accents
- Overused fonts, flat type hierarchy
Quality issues (general design and accessibility):
- Low contrast text (WCAG AA)
- Cramped padding, tight line height
- Skipped heading levels
- Line length too long
- Tiny body text, justified text
- Layout property animations
HOW IT WORKS
1. Install the extension
2. Open DevTools on any page (Cmd+Opt+I / F12)
3. Overlays appear automatically, highlighting issues
4. Click the "Impeccable" panel tab for a structured list of all findings
5. Click any finding to jump to the element in the Elements panel
FEATURES
- Auto-scans when DevTools opens, no manual step needed
- Grouped findings: AI tells vs. quality issues
- Click-to-inspect: jump from a finding to the element
- Toggle overlays on/off from the panel or toolbar popup
- Per-rule settings: disable detections you don't care about
- Re-scans on navigation, including SPA route changes
- Works on any website
- Runs 100% locally, no data sent anywhere
Open source at https://github.com/pbakaus/impeccable
## Category
Developer Tools
## Language
English
## Privacy policy URL
https://impeccable.style/privacy
## Single purpose description
Detects and highlights UI anti-patterns (AI-generated design tells and general quality issues) on any web page.
+94 -23
View File
@@ -13,7 +13,7 @@ const panelPorts = new Map();
function getState(tabId) {
if (!tabState.has(tabId)) {
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false });
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false, csInjected: false });
}
return tabState.get(tabId);
}
@@ -35,17 +35,47 @@ function notifyPanels(tabId, message) {
}
}
async function getDisabledRules() {
const result = await chrome.storage.sync.get({ disabledRules: [] });
return result.disabledRules;
async function getSettings() {
return chrome.storage.sync.get({
disabledRules: [],
lineLengthMode: 'strict', // 'strict' = 80, 'lax' = 120
spotlightBlur: true, // dim/blur the page on hover-highlight
autoScan: 'panel', // 'panel' = scan when Impeccable UI opens, 'devtools' = scan when DevTools opens
});
}
async function buildScanConfig() {
const disabledRules = await getDisabledRules();
return disabledRules.length ? { disabledRules } : null;
const { disabledRules, lineLengthMode, spotlightBlur } = await getSettings();
const config = {};
if (disabledRules.length) config.disabledRules = disabledRules;
config.lineLengthMax = lineLengthMode === 'lax' ? 120 : 80;
config.spotlightBlur = spotlightBlur;
return config;
}
// Inject the content script on-demand. We removed the static content_scripts entry to
// minimize the always-on footprint; the script is only loaded when the user explicitly
// engages with the extension (DevTools panel/sidebar opened, popup scan, etc).
async function ensureContentScriptInjected(tabId) {
const state = getState(tabId);
if (state.csInjected) return true;
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ['content/content-script.js'],
injectImmediately: true,
});
state.csInjected = true;
return true;
} catch (err) {
// Common cause: chrome:// pages, the web store, or other restricted URLs
return false;
}
}
async function sendScanToTab(tabId) {
const ok = await ensureContentScriptInjected(tabId);
if (!ok) return;
const config = await buildScanConfig();
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
}
@@ -75,6 +105,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ ok: true });
}
else if (msg.action === 'page-pointer-active' && tabId) {
notifyPanels(tabId, { action: 'page-pointer-active' });
sendResponse({ ok: true });
}
else if (msg.action === 'overlays-toggled' && tabId) {
const state = getState(tabId);
state.overlaysVisible = msg.visible;
@@ -115,6 +150,23 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Track which tabs have DevTools open (via the devtools.js lifecycle port)
const devtoolsTabs = new Set();
async function tearDownTab(tabId) {
devtoolsTabs.delete(tabId);
// Send the remove command and await it — this keeps the SW alive long enough
// to actually deliver the message (setTimeout doesn't survive SW termination in MV3).
try {
await chrome.tabs.sendMessage(tabId, { action: 'remove' });
} catch { /* tab might be closed or content script gone */ }
const state = tabState.get(tabId);
if (state) {
state.findings = [];
state.injected = false;
state.csInjected = false;
}
updateBadge(tabId);
panelPorts.delete(tabId);
}
// Handle long-lived connections from DevTools pages and panels
chrome.runtime.onConnect.addListener((port) => {
// Lifecycle port from devtools.js -- tracks DevTools open/close
@@ -124,19 +176,13 @@ chrome.runtime.onConnect.addListener((port) => {
port.onMessage.addListener((msg) => {
if (msg.action === 'scan') sendScanToTab(tabId);
// 'ping' is just a keepalive; no action needed
});
port.onDisconnect.addListener(() => {
devtoolsTabs.delete(tabId);
// DevTools closed -- remove overlays and clear state
chrome.tabs.sendMessage(tabId, { action: 'remove' }).catch(() => {});
const state = tabState.get(tabId);
if (state) {
state.findings = [];
state.injected = false;
}
updateBadge(tabId);
panelPorts.delete(tabId);
// Tear down immediately — defer with setTimeout doesn't work reliably in MV3
// because the SW can be terminated before the timer fires.
tearDownTab(tabId);
});
}
@@ -160,6 +206,10 @@ chrome.runtime.onConnect.addListener((port) => {
sendScanToTab(tabId);
} else if (msg.action === 'toggle-overlays') {
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
} else if (msg.action === 'highlight') {
chrome.tabs.sendMessage(tabId, { action: 'highlight', selector: msg.selector }).catch(() => {});
} else if (msg.action === 'unhighlight') {
chrome.tabs.sendMessage(tabId, { action: 'unhighlight' }).catch(() => {});
}
});
@@ -168,19 +218,40 @@ chrome.runtime.onConnect.addListener((port) => {
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
});
}
// Sidebar pane port (Elements panel sidebar) -- receives findings updates.
// Connecting the sidebar is a strong signal of "user engaged with Impeccable"
// so we trigger a scan if no findings exist yet (matches the panel port behavior).
if (port.name.startsWith('impeccable-sidebar-')) {
const tabId = parseInt(port.name.replace('impeccable-sidebar-', ''), 10);
if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
panelPorts.get(tabId).add(port);
const state = getState(tabId);
port.postMessage({ action: 'state', ...state });
if (!state.findings.length) sendScanToTab(tabId);
port.onDisconnect.addListener(() => {
panelPorts.get(tabId)?.delete(port);
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
});
}
});
// Re-scan on navigation (only if DevTools is open for that tab)
// Re-scan on navigation (only if DevTools is open AND user was actively scanning)
chrome.webNavigation?.onCompleted?.addListener((details) => {
if (details.frameId !== 0) return;
if (!devtoolsTabs.has(details.tabId)) return;
const state = tabState.get(details.tabId);
if (state) {
state.findings = [];
state.injected = false;
updateBadge(details.tabId);
notifyPanels(details.tabId, { action: 'navigated' });
// Re-inject and scan after a short delay for the page to settle
if (!state) return;
// Only re-scan if the user has actively engaged (had findings or injected previously)
const wasActive = state.injected || state.findings.length > 0;
state.findings = [];
state.injected = false;
state.csInjected = false; // page reload destroys the content script
updateBadge(details.tabId);
notifyPanels(details.tabId, { action: 'navigated' });
if (wasActive) {
setTimeout(() => sendScanToTab(details.tabId), 300);
}
});
+106 -79
View File
@@ -4,95 +4,122 @@
* Bridges between the extension messaging system and the page-context detector.
* The detector must run in page context (not isolated world) because it needs
* access to getComputedStyle, document.styleSheets.cssRules, etc.
*
* Wrapped in an IIFE with an idempotency flag so re-injection (via
* chrome.scripting.executeScript) is a no-op and doesn't cause:
* - SyntaxError: Identifier 'foo' has already been declared
* - Duplicate event listeners accumulating over time
*/
(function () {
if (window.__IMPECCABLE_CS_LOADED__) return;
window.__IMPECCABLE_CS_LOADED__ = true;
let injected = false;
let pendingScan = false;
let scanConfig = null;
let injected = false;
let pendingScan = false;
let scanConfig = null;
// Listen for commands from the service worker
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'scan') {
scanConfig = msg.config || null;
injectAndScan();
sendResponse({ ok: true });
} else if (msg.action === 'toggle-overlays') {
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
sendResponse({ ok: true });
} else if (msg.action === 'remove') {
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
injected = false;
sendResponse({ ok: true });
}
return true;
});
// Listen for commands from the service worker
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'scan') {
scanConfig = msg.config || null;
injectAndScan();
sendResponse({ ok: true });
} else if (msg.action === 'toggle-overlays') {
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
sendResponse({ ok: true });
} else if (msg.action === 'remove') {
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
injected = false;
sendResponse({ ok: true });
} else if (msg.action === 'highlight') {
window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*');
sendResponse({ ok: true });
} else if (msg.action === 'unhighlight') {
window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*');
sendResponse({ ok: true });
}
return true;
});
// Listen for results and state changes from the detector in page context
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data) return;
// Listen for results and state changes from the detector in page context
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data) return;
if (e.data.source === 'impeccable-results') {
chrome.runtime.sendMessage({
action: 'findings',
findings: e.data.findings,
count: e.data.count,
}).catch(() => {});
}
if (e.data.source === 'impeccable-results') {
chrome.runtime.sendMessage({
action: 'findings',
findings: e.data.findings,
count: e.data.count,
}).catch(() => {});
}
if (e.data.source === 'impeccable-overlays-toggled') {
chrome.runtime.sendMessage({
action: 'overlays-toggled',
visible: e.data.visible,
}).catch(() => {});
}
if (e.data.source === 'impeccable-overlays-toggled') {
chrome.runtime.sendMessage({
action: 'overlays-toggled',
visible: e.data.visible,
}).catch(() => {});
}
if (e.data.source === 'impeccable-ready') {
injected = true;
if (pendingScan) {
pendingScan = false;
sendScanCommand();
if (e.data.source === 'impeccable-ready') {
injected = true;
if (pendingScan) {
pendingScan = false;
sendScanCommand();
}
}
});
// Forward "page is active" signal to the extension when the cursor moves over the page.
// This is the reliable way to know the user has left the DevTools panel — the panel's
// own pointerleave/mouseleave events are unreliable on fast cursor movement.
let lastPageActive = 0;
document.addEventListener('pointermove', () => {
const now = Date.now();
if (now - lastPageActive < 150) return; // throttle
lastPageActive = now;
chrome.runtime.sendMessage({ action: 'page-pointer-active' }).catch(() => {});
}, { passive: true, capture: true });
// SPA navigation detection (pushState/replaceState don't fire events, but
// popstate and hashchange cover back/forward and hash navigation)
let lastUrl = location.href;
function onPossibleNavigation() {
if (location.href === lastUrl) return;
lastUrl = location.href;
if (injected) {
// Detector is still loaded in page context, just re-scan after DOM settles
setTimeout(sendScanCommand, 500);
}
}
});
window.addEventListener('popstate', onPossibleNavigation);
window.addEventListener('hashchange', onPossibleNavigation);
// SPA navigation detection (pushState/replaceState don't fire events, but
// popstate and hashchange cover back/forward and hash navigation)
let lastUrl = location.href;
function onPossibleNavigation() {
if (location.href === lastUrl) return;
lastUrl = location.href;
if (injected) {
// Detector is still loaded in page context, just re-scan after DOM settles
setTimeout(sendScanCommand, 500);
}
}
window.addEventListener('popstate', onPossibleNavigation);
window.addEventListener('hashchange', onPossibleNavigation);
function sendScanCommand() {
const msg = { source: 'impeccable-command', action: 'scan' };
if (scanConfig) msg.config = scanConfig;
window.postMessage(msg, '*');
}
function injectAndScan() {
if (injected) {
sendScanCommand();
return;
function sendScanCommand() {
const msg = { source: 'impeccable-command', action: 'scan' };
if (scanConfig) msg.config = scanConfig;
window.postMessage(msg, '*');
}
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
document.documentElement.dataset.impeccableExtension = 'true';
function injectAndScan() {
if (injected) {
sendScanCommand();
return;
}
// Inject the detector script into page context
const script = document.createElement('script');
script.src = chrome.runtime.getURL('detector/detect.js');
pendingScan = true;
script.onload = () => script.remove();
script.onerror = () => {
script.remove();
// Fallback: use chrome.scripting.executeScript for strict CSP pages
chrome.runtime.sendMessage({ action: 'inject-fallback' });
};
(document.head || document.documentElement).appendChild(script);
}
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
document.documentElement.dataset.impeccableExtension = 'true';
// Inject the detector script into page context
const script = document.createElement('script');
script.src = chrome.runtime.getURL('detector/detect.js');
script.dataset.impeccableExtension = 'true';
pendingScan = true;
script.onload = () => script.remove();
script.onerror = () => {
script.remove();
// Fallback: use chrome.scripting.executeScript for strict CSP pages
chrome.runtime.sendMessage({ action: 'inject-fallback' });
};
(document.head || document.documentElement).appendChild(script);
}
})();
+34 -5
View File
@@ -12,10 +12,39 @@ chrome.devtools.panels.create(
'devtools/panel.html'
);
// Connect a lifecycle port so the service worker knows when DevTools closes
const port = chrome.runtime.connect({
name: `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`,
// Sidebar pane in the Elements panel: shows findings for the currently selected element
chrome.devtools.panels.elements.createSidebarPane('Impeccable', (sidebar) => {
sidebar.setPage('devtools/sidebar.html');
sidebar.setHeight('200px');
});
// Auto-scan when DevTools opens (regardless of which panel is active).
port.postMessage({ action: 'scan' });
// Lifecycle port to the service worker. Auto-reconnects if the SW gets terminated
// (which can happen in MV3 after ~30s of inactivity, especially when the browser is unfocused).
const portName = `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`;
let lifecyclePort = null;
let firstConnect = true;
function connectLifecycle() {
lifecyclePort = chrome.runtime.connect({ name: portName });
// On the very first connection, decide whether to auto-scan based on the user's setting.
// Default ('panel'): wait until the user opens the Impeccable panel or sidebar.
// Opt-in ('devtools'): scan immediately when DevTools opens.
if (firstConnect) {
firstConnect = false;
chrome.storage.sync.get({ autoScan: 'panel' }, (settings) => {
if (settings.autoScan === 'devtools') {
try { lifecyclePort?.postMessage({ action: 'scan' }); } catch {}
}
});
}
lifecyclePort.onDisconnect.addListener(() => {
lifecyclePort = null;
// Reconnect on the next tick so the SW sees a fresh connection
setTimeout(connectLifecycle, 100);
});
}
connectLifecycle();
// Heartbeat to keep the SW alive
setInterval(() => {
try { lifecyclePort?.postMessage({ action: 'ping' }); } catch {}
}, 20000);
+167 -12
View File
@@ -113,12 +113,8 @@ h1 {
color: var(--text);
}
.tool-btn.active {
color: var(--accent);
}
.tool-btn.active:hover {
color: var(--text);
.tool-btn.inactive {
opacity: 0.4;
}
/* Findings */
@@ -192,6 +188,50 @@ h1 {
background: var(--bg-hover);
}
.finding-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.finding-row .finding-selector {
flex: 1;
min-width: 0;
}
.finding-copy {
display: none;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.finding-item:hover .finding-copy {
display: flex;
}
.finding-copy:hover {
background: var(--bg);
color: var(--accent);
}
.finding-copy.copied {
color: var(--accent);
}
.tool-btn.copied {
color: var(--accent);
}
.finding-selector {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 11px;
@@ -222,13 +262,35 @@ h1 {
display: block;
}
/* Page-level findings */
.page-level-tag {
font-size: 10px;
font-weight: 500;
color: var(--accent-dim);
/* Finding tags (page-level, hidden, etc.) */
.finding-tag {
display: inline-block;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
margin-bottom: 2px;
}
.tag-page {
color: var(--accent-dim);
background: transparent;
}
.tag-hidden {
color: var(--text-dim);
background: var(--bg-hover);
}
.finding-item.is-hidden {
opacity: 0.55;
cursor: default;
}
.finding-item.is-hidden:hover {
background: transparent;
}
/* Empty state */
@@ -315,10 +377,103 @@ h1 {
padding: 8px 8px 6px;
}
#settings-list {
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px 12px;
padding-bottom: 8px;
}
#settings-list .settings-header {
padding: 8px 8px 4px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px 8px;
gap: 12px;
}
.setting-label {
font-size: 11px;
color: var(--text);
}
.setting-segmented {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.setting-segmented button {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
font-family: inherit;
border-right: 1px solid var(--border);
}
.setting-segmented button:last-child {
border-right: none;
}
.setting-segmented button:hover {
color: var(--text);
}
.setting-segmented button.active {
background: var(--accent);
color: white;
}
.setting-switch {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.setting-switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.setting-switch-track {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--border);
border-radius: 8px;
transition: background 0.15s ease;
}
.setting-switch-track::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: var(--bg);
border-radius: 50%;
transition: transform 0.15s ease;
}
.setting-switch input:checked + .setting-switch-track {
background: var(--accent);
}
.setting-switch input:checked + .setting-switch-track::before {
transform: translateX(12px);
}
.setting-rule {
+26 -2
View File
@@ -12,10 +12,13 @@
<span class="badge" id="badge">0</span>
</div>
<div class="toolbar-right">
<button class="tool-btn" id="btn-copy-all" title="Copy all findings">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M11 1H3a2 2 0 0 0-2 2v10h2V3h8V1zm3 3H7a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 11H7V6h7v9z" fill="currentColor"/></svg>
</button>
<button class="tool-btn" id="btn-rescan" title="Re-scan page">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z" fill="currentColor"/></svg>
</button>
<button class="tool-btn active" id="btn-toggle" title="Toggle overlays">
<button class="tool-btn" id="btn-toggle" title="Toggle overlays">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3C4.36 3 1.26 5.28 0 8.5c1.26 3.22 4.36 5.5 8 5.5s6.74-2.28 8-5.5C14.74 5.28 11.64 3 8 3zm0 9.17c-2.58 0-4.67-2.09-4.67-4.67S5.42 2.83 8 2.83s4.67 2.09 4.67 4.67S10.58 12.17 8 12.17zM8 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z" fill="currentColor"/></svg>
</button>
<button class="tool-btn" id="btn-settings" title="Settings">
@@ -25,7 +28,28 @@
</header>
<div id="settings-container" style="display: none">
<div class="settings-header">Rules</div>
<div class="settings-header">Preferences</div>
<div class="setting-row">
<span class="setting-label">Auto-scan</span>
<div class="setting-segmented" id="auto-scan-mode">
<button data-value="panel">When panel opens</button>
<button data-value="devtools">When DevTools opens</button>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Line length</span>
<div class="setting-segmented" id="line-length-mode">
<button data-value="strict">Strict (80)</button>
<button data-value="lax">Lax (120)</button>
</div>
</div>
<div class="setting-row">
<span class="setting-label">Highlight blur</span>
<label class="setting-switch">
<input type="checkbox" id="spotlight-blur-toggle">
<span class="setting-switch-track"></span>
</label>
</div>
<div id="settings-list"></div>
</div>
+225 -23
View File
@@ -11,13 +11,34 @@ if (chrome.devtools.panels.themeName === 'dark') {
}
const tabId = chrome.devtools.inspectedWindow.tabId;
const port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
// Auto-reconnecting port. Service workers in MV3 can be terminated after ~30s of
// inactivity (especially when the browser window is unfocused). When they restart,
// the existing port becomes invalid. We recreate it lazily on the next use.
let port = null;
function getPort() {
if (port) return port;
port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
port.onMessage.addListener(handlePortMessage);
port.onDisconnect.addListener(() => { port = null; });
return port;
}
function postToPort(msg) {
try {
getPort().postMessage(msg);
} catch {
// Port died mid-call. Drop it and try once more with a fresh port.
port = null;
try { getPort().postMessage(msg); } catch { /* give up silently */ }
}
}
const badge = document.getElementById('badge');
const container = document.getElementById('findings-container');
const emptyState = document.getElementById('empty-state');
const btnRescan = document.getElementById('btn-rescan');
const btnToggle = document.getElementById('btn-toggle');
const btnCopyAll = document.getElementById('btn-copy-all');
const settingsContainer = document.getElementById('settings-container');
const settingsList = document.getElementById('settings-list');
const btnSettings = document.getElementById('btn-settings');
@@ -25,6 +46,7 @@ const btnSettings = document.getElementById('btn-settings');
let overlaysVisible = true;
let allAntipatterns = [];
let disabledRules = [];
let currentFindings = [];
// Load antipatterns list and disabled rules
async function initSettings() {
@@ -33,28 +55,100 @@ async function initSettings() {
allAntipatterns = await resp.json();
} catch { allAntipatterns = []; }
const stored = await chrome.storage.sync.get({ disabledRules: [] });
const stored = await chrome.storage.sync.get({
disabledRules: [],
lineLengthMode: 'strict',
spotlightBlur: true,
autoScan: 'panel',
});
disabledRules = stored.disabledRules;
renderSettings();
initLineLengthControl(stored.lineLengthMode);
initSpotlightBlurToggle(stored.spotlightBlur);
initAutoScanControl(stored.autoScan);
}
function initAutoScanControl(currentMode) {
const group = document.getElementById('auto-scan-mode');
if (!group) return;
for (const btn of group.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.value === currentMode);
btn.addEventListener('click', async () => {
const mode = btn.dataset.value;
for (const b of group.querySelectorAll('button')) {
b.classList.toggle('active', b === btn);
}
await chrome.storage.sync.set({ autoScan: mode });
});
}
}
function initLineLengthControl(currentMode) {
const group = document.getElementById('line-length-mode');
if (!group) return;
for (const btn of group.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.value === currentMode);
btn.addEventListener('click', async () => {
const mode = btn.dataset.value;
for (const b of group.querySelectorAll('button')) {
b.classList.toggle('active', b === btn);
}
await chrome.storage.sync.set({ lineLengthMode: mode });
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
});
}
}
function initSpotlightBlurToggle(currentValue) {
const cb = document.getElementById('spotlight-blur-toggle');
if (!cb) return;
cb.checked = currentValue;
cb.addEventListener('change', async () => {
await chrome.storage.sync.set({ spotlightBlur: cb.checked });
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
});
}
function renderSettings() {
settingsList.innerHTML = '';
const categories = {
slop: { label: 'AI tells', items: [] },
quality: { label: 'Quality', items: [] },
};
for (const ap of allAntipatterns) {
const label = document.createElement('label');
label.className = 'setting-rule';
const cat = ap.category || 'quality';
(categories[cat] || categories.quality).items.push(ap);
}
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !disabledRules.includes(ap.id);
checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
for (const [, group] of Object.entries(categories)) {
if (!group.items.length) continue;
const text = document.createElement('span');
text.textContent = ap.name;
const header = document.createElement('div');
header.className = 'settings-header';
header.textContent = group.label;
settingsList.appendChild(header);
label.appendChild(checkbox);
label.appendChild(text);
settingsList.appendChild(label);
const grid = document.createElement('div');
grid.className = 'settings-grid';
for (const ap of group.items) {
const label = document.createElement('label');
label.className = 'setting-rule';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !disabledRules.includes(ap.id);
checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
const text = document.createElement('span');
text.textContent = ap.name;
label.appendChild(checkbox);
label.appendChild(text);
grid.appendChild(label);
}
settingsList.appendChild(grid);
}
}
@@ -68,8 +162,13 @@ async function toggleRule(ruleId, enabled) {
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
}
// Listen for messages from the service worker
port.onMessage.addListener((msg) => {
// Listen for messages from the service worker (called by getPort() on each new connection)
function handlePortMessage(msg) {
if (msg.action === 'page-pointer-active') {
// Cursor is active on the page → user has left the panel
setHoveredItem(null);
return;
}
if (msg.action === 'findings' || msg.action === 'state') {
renderFindings(msg.findings || []);
if (msg.overlaysVisible !== undefined) {
@@ -84,16 +183,23 @@ port.onMessage.addListener((msg) => {
if (msg.action === 'navigated') {
showScanning();
}
});
}
// Initial connection
getPort();
// Heartbeat to keep the MV3 service worker alive while the panel is open.
// SWs can be terminated after ~30s of inactivity, especially when the browser is unfocused.
setInterval(() => postToPort({ action: 'ping' }), 20000);
// Controls
btnRescan.addEventListener('click', () => {
showScanning();
port.postMessage({ action: 'scan' });
postToPort({ action: 'scan' });
});
btnToggle.addEventListener('click', () => {
port.postMessage({ action: 'toggle-overlays' });
postToPort({ action: 'toggle-overlays' });
});
btnSettings.addEventListener('click', () => {
@@ -103,8 +209,8 @@ btnSettings.addEventListener('click', () => {
});
function updateToggleButton() {
btnToggle.classList.toggle('active', overlaysVisible);
btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays';
btnToggle.classList.toggle('inactive', !overlaysVisible);
}
function showScanning() {
@@ -115,7 +221,86 @@ function showScanning() {
</div>`;
}
function formatFindingsForCopy(findings) {
if (!findings.length) return 'No anti-patterns detected.';
const lines = ['Impeccable detected the following UI anti-patterns:', ''];
const groups = { slop: [], quality: [] };
for (const item of findings) {
for (const f of item.findings) {
const cat = f.category || 'quality';
groups[cat].push({ ...f, selector: item.selector, isPageLevel: item.isPageLevel });
}
}
if (groups.slop.length) {
lines.push('## AI tells');
for (const f of groups.slop) {
lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`);
}
lines.push('');
}
if (groups.quality.length) {
lines.push('## Quality issues');
for (const f of groups.quality) {
lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`);
}
lines.push('');
}
lines.push('Please fix these issues.');
return lines.join('\n');
}
function formatSingleFindingForCopy(item, finding) {
const where = item.isPageLevel ? '(page-level)' : `at \`${item.selector}\``;
return `Impeccable detected: ${finding.name} ${where}\nDetail: ${finding.detail}\n\n${finding.description}\n\nPlease fix this.`;
}
async function copyToClipboard(text, btn) {
try {
await navigator.clipboard.writeText(text);
if (btn) {
const orig = btn.title;
btn.title = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {
btn.title = orig;
btn.classList.remove('copied');
}, 1200);
}
} catch (err) {
console.warn('Copy failed', err);
}
}
btnCopyAll.addEventListener('click', () => {
copyToClipboard(formatFindingsForCopy(currentFindings), btnCopyAll);
});
// Delegated hover tracking on the findings container.
// Reliably handles cursor moving between items, into children, or out of the panel.
let currentHoverSelector = null;
function setHoveredItem(selector) {
if (selector === currentHoverSelector) return;
currentHoverSelector = selector;
if (selector) {
postToPort({ action: 'highlight', selector });
} else {
postToPort({ action: 'unhighlight' });
}
}
container.addEventListener('pointermove', (e) => {
const item = e.target.closest('.finding-item');
const selector = item && !item.classList.contains('is-hidden') ? item.dataset.selector || null : null;
setHoveredItem(selector);
});
// Slow-cursor fallbacks (these fire reliably for slow movements)
container.addEventListener('pointerleave', () => setHoveredItem(null));
window.addEventListener('blur', () => setHoveredItem(null));
function renderFindings(findings) {
currentFindings = findings;
if (!findings.length) {
container.innerHTML = '';
container.appendChild(emptyState);
@@ -145,6 +330,7 @@ function renderFindings(findings) {
selector: item.selector,
tagName: item.tagName,
isPageLevel: item.isPageLevel,
isHidden: item.isHidden,
detail: f.detail,
});
}
@@ -186,14 +372,30 @@ function renderFindings(findings) {
for (const item of group.items) {
const itemEl = document.createElement('div');
itemEl.className = 'finding-item';
itemEl.className = 'finding-item' + (item.isHidden ? ' is-hidden' : '');
const tag = item.isPageLevel
? '<span class="finding-tag tag-page">page</span>'
: item.isHidden ? '<span class="finding-tag tag-hidden" title="Element is currently hidden on the page">hidden</span>' : '';
itemEl.innerHTML = `
${item.isPageLevel ? '<span class="page-level-tag">page</span>' : ''}
<span class="finding-selector">${escapeHtml(item.selector)}</span>
${tag}
<div class="finding-row">
<span class="finding-selector">${escapeHtml(item.selector)}</span>
<button class="finding-copy" title="Copy this finding">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><path d="M11 1H3a2 2 0 0 0-2 2v10h2V3h8V1zm3 3H7a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 11H7V6h7v9z" fill="currentColor"/></svg>
</button>
</div>
<span class="finding-detail">${escapeHtml(item.detail)}</span>
<span class="finding-description">${escapeHtml(group.description)}</span>`;
if (!item.isPageLevel) {
const copyBtn = itemEl.querySelector('.finding-copy');
const finding = { name: group.name, description: group.description, detail: item.detail };
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
copyToClipboard(formatSingleFindingForCopy(item, finding), copyBtn);
});
if (!item.isPageLevel && !item.isHidden) {
itemEl.dataset.selector = item.selector;
itemEl.addEventListener('click', () => inspectElement(item.selector));
}
+97
View File
@@ -0,0 +1,97 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #fff;
--text: #1a1a1a;
--text-dim: #666;
--text-faint: #999;
--accent: oklch(48% 0.25 350);
--rule: #e8e6e2;
}
.theme-dark {
--bg: #1a1a1a;
--text: #f5f3ef;
--text-dim: #9a9590;
--text-faint: #666;
--accent: oklch(60% 0.25 350);
--rule: #2a2a2a;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 12px;
line-height: 1.5;
padding: 12px 14px;
}
/* Empty / no-findings states */
.state {
color: var(--text-faint);
font-size: 12px;
font-style: italic;
padding: 4px 0;
}
.state strong {
color: var(--text-dim);
font-style: normal;
font-weight: 600;
}
/* Finding list */
.finding + .finding {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--rule);
}
.finding-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.finding-name {
font-weight: 600;
font-size: 12px;
color: var(--text);
letter-spacing: -0.005em;
}
.finding-name .marker {
color: var(--accent);
margin-right: 4px;
}
.finding-kind {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-faint);
flex-shrink: 0;
}
.finding-detail {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 11px;
color: var(--text-dim);
margin-bottom: 6px;
}
.finding-description {
font-size: 11px;
color: var(--text-dim);
line-height: 1.55;
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="sidebar.css">
</head>
<body>
<div id="sidebar-content">
<div class="state">Select an element to see Impeccable findings.</div>
</div>
<script src="sidebar.js"></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
/**
* Impeccable DevTools Extension - Elements Sidebar Pane
*
* Shows Impeccable findings for the currently selected element ($0) in the Elements panel.
*/
if (chrome.devtools.panels.themeName === 'dark') {
document.documentElement.classList.add('theme-dark');
}
const tabId = chrome.devtools.inspectedWindow.tabId;
const content = document.getElementById('sidebar-content');
let currentFindings = [];
// Auto-reconnecting port (service worker may restart in MV3)
let port = null;
function getPort() {
if (port) return port;
port = chrome.runtime.connect({ name: `impeccable-sidebar-${tabId}` });
port.onMessage.addListener((msg) => {
if (msg.action === 'findings' || msg.action === 'state') {
currentFindings = msg.findings || [];
refreshForCurrentSelection();
}
});
port.onDisconnect.addListener(() => { port = null; });
return port;
}
getPort();
chrome.devtools.panels.elements.onSelectionChanged.addListener(refreshForCurrentSelection);
function refreshForCurrentSelection() {
if (!currentFindings.length) {
renderEmpty('No findings on this page yet.');
return;
}
// Collect non-page-level selectors and ask the inspected window which one matches $0
const selectors = [];
for (const item of currentFindings) {
if (item.isPageLevel || item.isHidden) continue;
selectors.push(item.selector);
}
if (!selectors.length) {
renderEmpty('No element-level findings on this page.');
return;
}
const code = `(function() {
var sels = ${JSON.stringify(selectors)};
var matched = [];
for (var i = 0; i < sels.length; i++) {
try { if (document.querySelector(sels[i]) === $0) matched.push(sels[i]); } catch (e) {}
}
return matched;
})()`;
chrome.devtools.inspectedWindow.eval(code, (matched) => {
if (!matched || !matched.length) {
renderNoFindings();
return;
}
const items = currentFindings.filter(item => matched.includes(item.selector));
render(items);
});
}
function renderEmpty(text) {
content.innerHTML = `<div class="state">${escapeHtml(text)}</div>`;
}
function renderNoFindings() {
content.innerHTML = `<div class="state"><strong>Clean.</strong> No anti-patterns on this element.</div>`;
}
function render(items) {
const html = [];
for (const item of items) {
for (const f of item.findings) {
const isSlop = f.category === 'slop';
const marker = isSlop ? '<span class="marker">\u2726</span>' : '';
const kind = isSlop ? 'AI tell' : 'Quality';
html.push(`
<div class="finding">
<div class="finding-header">
<span class="finding-name">${marker}${escapeHtml(f.name)}</span>
<span class="finding-kind">${kind}</span>
</div>
<div class="finding-detail">${escapeHtml(f.detail)}</div>
<div class="finding-description">${escapeHtml(f.description)}</div>
</div>
`);
}
}
content.innerHTML = html.join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

-7
View File
@@ -8,13 +8,6 @@
"background": {
"service_worker": "background/service-worker.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content-script.js"],
"run_at": "document_idle"
}
],
"devtools_page": "devtools/devtools.html",
"action": {
"default_popup": "popup/popup.html",