Add Chrome DevTools extension for anti-pattern detection

Adds a Manifest V3 Chrome extension that injects the detector when
DevTools opens, with a dedicated panel for browsing findings, a toolbar
popup for quick scan/toggle, and per-rule settings synced via
chrome.storage. Categorizes anti-patterns into AI slop vs quality
issues with visual differentiation (sparkle prefix, panel grouping).
Overlay labels are polished with flush positioning, cycling for
multi-finding elements, and synchronized hover darkening.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-06 15:18:36 -07:00
co-authored by Claude Opus 4.6
parent 9ccdb9148a
commit e961d56252
22 changed files with 1921 additions and 214 deletions
+3
View File
@@ -24,5 +24,8 @@ Thumbs.db
# Cloudflare
.wrangler/
# Extension build artifacts
extension/detector/
# User design context
.impeccable.md
+192
View File
@@ -0,0 +1,192 @@
/**
* Impeccable DevTools Extension - Service Worker
*
* Routes messages between popup, DevTools panel, and content scripts.
* Maintains per-tab state and updates the badge.
*/
// Per-tab state: { tabId: { findings, overlaysVisible, injected } }
const tabState = new Map();
// Active DevTools panel connections: { tabId: Set<port> }
const panelPorts = new Map();
function getState(tabId) {
if (!tabState.has(tabId)) {
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false });
}
return tabState.get(tabId);
}
function updateBadge(tabId) {
const state = tabState.get(tabId);
const count = state?.findings?.length || 0;
const text = count > 0 ? String(count) : '';
chrome.action.setBadgeText({ text, tabId }).catch(() => {});
chrome.action.setBadgeBackgroundColor({ color: '#d6336c', tabId }).catch(() => {});
}
function notifyPanels(tabId, message) {
const ports = panelPorts.get(tabId);
if (ports) {
for (const port of ports) {
try { port.postMessage(message); } catch { /* port disconnected */ }
}
}
}
async function getDisabledRules() {
const result = await chrome.storage.sync.get({ disabledRules: [] });
return result.disabledRules;
}
async function buildScanConfig() {
const disabledRules = await getDisabledRules();
return disabledRules.length ? { disabledRules } : null;
}
async function sendScanToTab(tabId) {
const config = await buildScanConfig();
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
}
// Handle messages from content scripts and popup
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
const tabId = msg.tabId || sender.tab?.id;
if (msg.action === 'findings' && tabId) {
const state = getState(tabId);
state.findings = msg.findings || [];
state.injected = true;
updateBadge(tabId);
notifyPanels(tabId, { action: 'findings', findings: state.findings });
// Broadcast for popup
chrome.runtime.sendMessage({ action: 'findings-updated', tabId, findings: state.findings }).catch(() => {});
sendResponse({ ok: true });
}
else if (msg.action === 'scan' && tabId) {
sendScanToTab(tabId);
sendResponse({ ok: true });
}
else if (msg.action === 'toggle-overlays' && tabId) {
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
sendResponse({ ok: true });
}
else if (msg.action === 'overlays-toggled' && tabId) {
const state = getState(tabId);
state.overlaysVisible = msg.visible;
notifyPanels(tabId, { action: 'overlays-toggled', visible: msg.visible });
chrome.runtime.sendMessage({ action: 'overlays-toggled-broadcast', tabId, visible: msg.visible }).catch(() => {});
sendResponse({ ok: true });
}
else if (msg.action === 'get-state' && tabId) {
sendResponse(getState(tabId));
}
else if (msg.action === 'inject-fallback' && tabId) {
// CSP fallback: inject detector via chrome.scripting (bypasses page CSP)
chrome.scripting.executeScript({
target: { tabId },
world: 'MAIN',
files: ['detector/detect.js'],
}).then(() => {
// Detector will post impeccable-ready, content script handles the rest
}).catch((err) => {
console.warn('[impeccable] Fallback injection failed:', err);
});
sendResponse({ ok: true });
}
else if (msg.action === 'disabled-rules-changed') {
// Re-scan all tabs that have been injected
for (const [tid, state] of tabState) {
if (state.injected) sendScanToTab(tid);
}
sendResponse({ ok: true });
}
return true;
});
// Track which tabs have DevTools open (via the devtools.js lifecycle port)
const devtoolsTabs = new Set();
// Handle long-lived connections from DevTools pages and panels
chrome.runtime.onConnect.addListener((port) => {
// Lifecycle port from devtools.js -- tracks DevTools open/close
if (port.name.startsWith('impeccable-devtools-')) {
const tabId = parseInt(port.name.replace('impeccable-devtools-', ''), 10);
devtoolsTabs.add(tabId);
port.onMessage.addListener((msg) => {
if (msg.action === 'scan') sendScanToTab(tabId);
});
port.onDisconnect.addListener(() => {
devtoolsTabs.delete(tabId);
// DevTools closed -- remove overlays and clear state
chrome.tabs.sendMessage(tabId, { action: 'remove' }).catch(() => {});
const state = tabState.get(tabId);
if (state) {
state.findings = [];
state.injected = false;
}
updateBadge(tabId);
panelPorts.delete(tabId);
});
}
// Panel port from panel.js -- for forwarding findings/state
if (port.name.startsWith('impeccable-panel-')) {
const tabId = parseInt(port.name.replace('impeccable-panel-', ''), 10);
if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
panelPorts.get(tabId).add(port);
// Send current state to newly connected panel
const state = getState(tabId);
port.postMessage({ action: 'state', ...state });
// If no findings yet, the auto-scan from devtools.js may have been lost -- trigger one
if (!state.findings.length) {
sendScanToTab(tabId);
}
port.onMessage.addListener((msg) => {
if (msg.action === 'scan') {
sendScanToTab(tabId);
} else if (msg.action === 'toggle-overlays') {
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
}
});
port.onDisconnect.addListener(() => {
panelPorts.get(tabId)?.delete(port);
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
});
}
});
// Re-scan on navigation (only if DevTools is open for that tab)
chrome.webNavigation?.onCompleted?.addListener((details) => {
if (details.frameId !== 0) return;
if (!devtoolsTabs.has(details.tabId)) return;
const state = tabState.get(details.tabId);
if (state) {
state.findings = [];
state.injected = false;
updateBadge(details.tabId);
notifyPanels(details.tabId, { action: 'navigated' });
// Re-inject and scan after a short delay for the page to settle
setTimeout(() => sendScanToTab(details.tabId), 300);
}
});
// Clean up state when tabs close
chrome.tabs.onRemoved.addListener((tabId) => {
tabState.delete(tabId);
panelPorts.delete(tabId);
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Impeccable DevTools Extension - Content Script
*
* Bridges between the extension messaging system and the page-context detector.
* The detector must run in page context (not isolated world) because it needs
* access to getComputedStyle, document.styleSheets.cssRules, etc.
*/
let injected = false;
let pendingScan = false;
let scanConfig = null;
// Listen for commands from the service worker
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.action === 'scan') {
scanConfig = msg.config || null;
injectAndScan();
sendResponse({ ok: true });
} else if (msg.action === 'toggle-overlays') {
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
sendResponse({ ok: true });
} else if (msg.action === 'remove') {
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
injected = false;
sendResponse({ ok: true });
}
return true;
});
// Listen for results and state changes from the detector in page context
window.addEventListener('message', (e) => {
if (e.source !== window || !e.data) return;
if (e.data.source === 'impeccable-results') {
chrome.runtime.sendMessage({
action: 'findings',
findings: e.data.findings,
count: e.data.count,
}).catch(() => {});
}
if (e.data.source === 'impeccable-overlays-toggled') {
chrome.runtime.sendMessage({
action: 'overlays-toggled',
visible: e.data.visible,
}).catch(() => {});
}
if (e.data.source === 'impeccable-ready') {
injected = true;
if (pendingScan) {
pendingScan = false;
sendScanCommand();
}
}
});
// SPA navigation detection (pushState/replaceState don't fire events, but
// popstate and hashchange cover back/forward and hash navigation)
let lastUrl = location.href;
function onPossibleNavigation() {
if (location.href === lastUrl) return;
lastUrl = location.href;
if (injected) {
// Detector is still loaded in page context, just re-scan after DOM settles
setTimeout(sendScanCommand, 500);
}
}
window.addEventListener('popstate', onPossibleNavigation);
window.addEventListener('hashchange', onPossibleNavigation);
function sendScanCommand() {
const msg = { source: 'impeccable-command', action: 'scan' };
if (scanConfig) msg.config = scanConfig;
window.postMessage(msg, '*');
}
function injectAndScan() {
if (injected) {
sendScanCommand();
return;
}
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
document.documentElement.dataset.impeccableExtension = 'true';
// Inject the detector script into page context
const script = document.createElement('script');
script.src = chrome.runtime.getURL('detector/detect.js');
pendingScan = true;
script.onload = () => script.remove();
script.onerror = () => {
script.remove();
// Fallback: use chrome.scripting.executeScript for strict CSP pages
chrome.runtime.sendMessage({ action: 'inject-fallback' });
};
(document.head || document.documentElement).appendChild(script);
}
+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body><script src="devtools.js"></script></body>
</html>
+21
View File
@@ -0,0 +1,21 @@
/**
* Impeccable DevTools Extension - DevTools Page
*
* Creates the Impeccable panel and triggers an auto-scan when DevTools opens.
* This page lives for the entire DevTools session -- its port disconnect
* is the canonical signal that DevTools has closed.
*/
chrome.devtools.panels.create(
'Impeccable',
'icons/icon-32.png',
'devtools/panel.html'
);
// Connect a lifecycle port so the service worker knows when DevTools closes
const port = chrome.runtime.connect({
name: `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`,
});
// Auto-scan when DevTools opens (regardless of which panel is active).
port.postMessage({ action: 'scan' });
+365
View File
@@ -0,0 +1,365 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Light theme (default DevTools) */
:root {
--bg: #fff;
--bg-subtle: #f5f5f5;
--bg-hover: #eee;
--text: #1a1a1a;
--text-dim: #666;
--accent: oklch(48% 0.25 350);
--accent-dim: oklch(40% 0.18 350);
--border: #ddd;
--radius: 6px;
}
/* Dark theme (set via JS from chrome.devtools.panels.themeName) */
.theme-dark {
--bg: #1a1a1a;
--bg-subtle: #242424;
--bg-hover: #2a2a2a;
--text: #f5f3ef;
--text-dim: #999;
--accent: oklch(55% 0.25 350);
--accent-dim: oklch(45% 0.18 350);
--border: #333;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
height: 100vh;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
background: var(--bg);
position: sticky;
top: 0;
z-index: 10;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.logo {
font-size: 18px;
font-weight: 500;
color: var(--text);
opacity: 0.7;
}
h1 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
}
.badge {
background: var(--accent);
color: white;
font-size: 11px;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
display: none;
}
.badge.visible {
display: inline-block;
}
/* Tool buttons */
.tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tool-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.tool-btn.active {
color: var(--accent);
}
.tool-btn.active:hover {
color: var(--text);
}
/* Findings */
#findings-container {
padding: 8px;
}
.finding-group {
margin-bottom: 2px;
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius);
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.group-header:hover {
background: var(--bg-hover);
}
.group-chevron {
font-size: 10px;
color: var(--text-dim);
transition: transform 0.15s;
width: 12px;
flex-shrink: 0;
}
.group-header.collapsed .group-chevron {
transform: rotate(-90deg);
}
.group-name {
font-weight: 600;
font-size: 12px;
flex: 1;
min-width: 0;
}
.group-count {
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
}
.group-items {
overflow: hidden;
}
.group-header.collapsed + .group-items {
display: none;
}
.finding-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 8px 5px 28px;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s;
}
.finding-item:hover {
background: var(--bg-hover);
}
.finding-selector {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 11px;
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.finding-detail {
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.finding-description {
font-size: 11px;
color: var(--text-dim);
opacity: 0.7;
line-height: 1.4;
padding: 2px 0 4px;
display: none;
}
.finding-item:hover .finding-description {
display: block;
}
/* Page-level findings */
.page-level-tag {
font-size: 10px;
font-weight: 500;
color: var(--accent-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
}
.empty-icon {
font-size: 32px;
font-weight: 500;
opacity: 0.3;
margin-bottom: 12px;
}
.empty-title {
font-size: 13px;
font-weight: 500;
margin-bottom: 4px;
}
.empty-hint {
font-size: 12px;
color: var(--text-dim);
}
/* Category sections */
.category-section {
margin-bottom: 4px;
}
.category-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 4px;
}
.category-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.category-dot-slop {
background: oklch(55% 0.25 350);
}
.category-dot-quality {
background: var(--text-dim);
}
.category-name {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.category-count {
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
}
/* Settings */
#settings-container {
border-bottom: 1px solid var(--border);
padding: 0 8px 8px;
}
.settings-header {
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 8px 6px;
}
#settings-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px 12px;
}
.setting-rule {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
border-radius: var(--radius);
font-size: 11px;
cursor: pointer;
transition: background 0.15s;
}
.setting-rule:hover {
background: var(--bg-hover);
}
.setting-rule input[type="checkbox"] {
margin: 0;
accent-color: var(--accent);
}
/* Scanning state */
.scanning-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--text-dim);
font-size: 12px;
}
.scanning-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
+42
View File
@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="panel.css">
</head>
<body>
<header class="toolbar">
<div class="toolbar-left">
<span class="logo">/</span>
<h1>Impeccable</h1>
<span class="badge" id="badge">0</span>
</div>
<div class="toolbar-right">
<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">
<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">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M13.6 8.8c.04-.26.06-.53.06-.8s-.02-.54-.06-.8l1.74-1.36a.42.42 0 0 0 .1-.52l-1.64-2.84a.41.41 0 0 0-.5-.18l-2.06.82a5.96 5.96 0 0 0-1.38-.8L9.5.2A.4.4 0 0 0 9.1 0H5.82a.4.4 0 0 0-.4.34l-.3 2.12c-.5.2-.96.48-1.38.8l-2.06-.82a.4.4 0 0 0-.5.18L-.46 5.46a.41.41 0 0 0 .1.52L1.38 7.34c-.04.26-.06.53-.06.8s.02.54.06.8L-.36 10.3a.42.42 0 0 0-.1.52l1.64 2.84c.1.18.32.24.5.18l2.06-.82c.42.32.88.6 1.38.8l.3 2.12a.4.4 0 0 0 .4.34h3.28a.4.4 0 0 0 .4-.34l.3-2.12c.5-.2.96-.48 1.38-.8l2.06.82c.18.08.4 0 .5-.18l1.64-2.84a.41.41 0 0 0-.1-.52L13.6 8.8zM7.46 10.8c-1.56 0-2.82-1.26-2.82-2.8s1.26-2.8 2.82-2.8 2.82 1.26 2.82 2.8-1.26 2.8-2.82 2.8z" fill="currentColor"/></svg>
</button>
</div>
</header>
<div id="settings-container" style="display: none">
<div class="settings-header">Rules</div>
<div id="settings-list"></div>
</div>
<main id="findings-container">
<div class="empty-state" id="empty-state">
<div class="empty-icon">/</div>
<p class="empty-title">No anti-patterns detected</p>
<p class="empty-hint">Overlays will appear on the page when issues are found</p>
</div>
</main>
<script src="panel.js"></script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
/**
* Impeccable DevTools Extension - Panel
*
* Displays findings, provides controls for scanning and overlay toggling,
* and allows clicking findings to inspect elements.
*/
// Match the DevTools theme (light or dark)
if (chrome.devtools.panels.themeName === 'dark') {
document.documentElement.classList.add('theme-dark');
}
const tabId = chrome.devtools.inspectedWindow.tabId;
const port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
const badge = document.getElementById('badge');
const container = document.getElementById('findings-container');
const emptyState = document.getElementById('empty-state');
const btnRescan = document.getElementById('btn-rescan');
const btnToggle = document.getElementById('btn-toggle');
const settingsContainer = document.getElementById('settings-container');
const settingsList = document.getElementById('settings-list');
const btnSettings = document.getElementById('btn-settings');
let overlaysVisible = true;
let allAntipatterns = [];
let disabledRules = [];
// Load antipatterns list and disabled rules
async function initSettings() {
try {
const resp = await fetch(chrome.runtime.getURL('detector/antipatterns.json'));
allAntipatterns = await resp.json();
} catch { allAntipatterns = []; }
const stored = await chrome.storage.sync.get({ disabledRules: [] });
disabledRules = stored.disabledRules;
renderSettings();
}
function renderSettings() {
settingsList.innerHTML = '';
for (const ap of allAntipatterns) {
const label = document.createElement('label');
label.className = 'setting-rule';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = !disabledRules.includes(ap.id);
checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
const text = document.createElement('span');
text.textContent = ap.name;
label.appendChild(checkbox);
label.appendChild(text);
settingsList.appendChild(label);
}
}
async function toggleRule(ruleId, enabled) {
if (enabled) {
disabledRules = disabledRules.filter(id => id !== ruleId);
} else {
if (!disabledRules.includes(ruleId)) disabledRules.push(ruleId);
}
await chrome.storage.sync.set({ disabledRules });
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
}
// Listen for messages from the service worker
port.onMessage.addListener((msg) => {
if (msg.action === 'findings' || msg.action === 'state') {
renderFindings(msg.findings || []);
if (msg.overlaysVisible !== undefined) {
overlaysVisible = msg.overlaysVisible;
updateToggleButton();
}
}
if (msg.action === 'overlays-toggled') {
overlaysVisible = msg.visible;
updateToggleButton();
}
if (msg.action === 'navigated') {
showScanning();
}
});
// Controls
btnRescan.addEventListener('click', () => {
showScanning();
port.postMessage({ action: 'scan' });
});
btnToggle.addEventListener('click', () => {
port.postMessage({ action: 'toggle-overlays' });
});
btnSettings.addEventListener('click', () => {
const isVisible = settingsContainer.style.display !== 'none';
settingsContainer.style.display = isVisible ? 'none' : '';
btnSettings.classList.toggle('active', !isVisible);
});
function updateToggleButton() {
btnToggle.classList.toggle('active', overlaysVisible);
btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays';
}
function showScanning() {
container.innerHTML = `
<div class="scanning-indicator">
<div class="scanning-dot"></div>
Scanning page...
</div>`;
}
function renderFindings(findings) {
if (!findings.length) {
container.innerHTML = '';
container.appendChild(emptyState);
emptyState.style.display = '';
badge.classList.remove('visible');
badge.textContent = '0';
return;
}
emptyState.style.display = 'none';
// Count total element-level findings
const totalCount = findings.reduce((sum, f) => sum + f.findings.length, 0);
badge.textContent = String(totalCount);
badge.classList.add('visible');
// Group findings by category, then by anti-pattern type
const categories = { slop: new Map(), quality: new Map() };
for (const item of findings) {
for (const f of item.findings) {
const cat = f.category || 'quality';
const groups = categories[cat] || categories.quality;
if (!groups.has(f.type)) {
groups.set(f.type, { name: f.name, description: f.description, items: [] });
}
groups.get(f.type).items.push({
selector: item.selector,
tagName: item.tagName,
isPageLevel: item.isPageLevel,
detail: f.detail,
});
}
}
container.innerHTML = '';
const CATEGORY_LABELS = { slop: 'AI tells', quality: 'Quality issues' };
for (const [catKey, groups] of Object.entries(categories)) {
if (groups.size === 0) continue;
const catCount = [...groups.values()].reduce((sum, g) => sum + g.items.length, 0);
const section = document.createElement('div');
section.className = 'category-section category-' + catKey;
const catHeader = document.createElement('div');
catHeader.className = 'category-header';
catHeader.innerHTML = `
<span class="category-dot category-dot-${catKey}"></span>
<span class="category-name">${CATEGORY_LABELS[catKey]}</span>
<span class="category-count">${catCount}</span>`;
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 = `
<span class="group-chevron">&#9660;</span>
<span class="group-name">${escapeHtml(group.name)}</span>
<span class="group-count">${group.items.length}</span>`;
header.addEventListener('click', () => header.classList.toggle('collapsed'));
groupEl.appendChild(header);
const itemsEl = document.createElement('div');
itemsEl.className = 'group-items';
for (const item of group.items) {
const itemEl = document.createElement('div');
itemEl.className = 'finding-item';
itemEl.innerHTML = `
${item.isPageLevel ? '<span class="page-level-tag">page</span>' : ''}
<span class="finding-selector">${escapeHtml(item.selector)}</span>
<span class="finding-detail">${escapeHtml(item.detail)}</span>
<span class="finding-description">${escapeHtml(group.description)}</span>`;
if (!item.isPageLevel) {
itemEl.addEventListener('click', () => inspectElement(item.selector));
}
itemsEl.appendChild(itemEl);
}
groupEl.appendChild(itemsEl);
section.appendChild(groupEl);
}
container.appendChild(section);
}
}
function inspectElement(selector) {
const escaped = selector.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
chrome.devtools.inspectedWindow.eval(
`(function() {
var el = document.querySelector('${escaped}');
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); inspect(el); }
})()`
);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
initSettings();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#1a1a1a"/>
<line x1="76" y1="24" x2="52" y2="104" stroke="#f5f3ef" stroke-width="7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

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