mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.6
parent
9ccdb9148a
commit
e961d56252
@@ -24,5 +24,8 @@ Thumbs.db
|
||||
# Cloudflare
|
||||
.wrangler/
|
||||
|
||||
# Extension build artifacts
|
||||
extension/detector/
|
||||
|
||||
# User design context
|
||||
.impeccable.md
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body><script src="devtools.js"></script></body>
|
||||
</html>
|
||||
@@ -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' });
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">▼</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 |
@@ -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 |
@@ -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>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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)`);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user