mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.6
parent
e961d56252
commit
13b2f763d9
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user