mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Polish Chrome extension for Web Store submission
Refactors the extension for on-demand injection (no static content_scripts entry — content script and detector are loaded only when the user actively opens the Impeccable panel, sidebar pane, or popup). Adds a new "Auto-scan" preference (default: scan when the Impeccable panel opens, opt-in: scan when DevTools opens) plus configurable line length (strict/lax) and highlight blur on/off settings. Adds an Elements panel sidebar that shows findings for the currently selected element. Includes substantial overlay UX work: page-pixel-perfect spotlight mask via clip-path, refined hover/dim states, instant transitions for snappier feel, copy buttons for findings, hover-from-panel highlighting, and a brand-aware exception list so the font check no longer flags Roboto on Google's own properties. Robustness fixes for the MV3 service worker lifecycle: heartbeat keepalive plus auto-reconnecting ports across panel/sidebar/devtools so transient SW restarts don't break the panel UI, and immediate teardown on DevTools close (replacing an unreliable setTimeout-based defer that didn't survive SW termination). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e961d56252
commit
13b2f763d9
@@ -0,0 +1,63 @@
|
|||||||
|
# Chrome Web Store Listing
|
||||||
|
|
||||||
|
## Name
|
||||||
|
Impeccable
|
||||||
|
|
||||||
|
## Short description (132 chars max)
|
||||||
|
Detect AI slop and design anti-patterns in any web page. Open DevTools and see what needs fixing.
|
||||||
|
|
||||||
|
## Detailed description
|
||||||
|
|
||||||
|
Impeccable detects 24 common UI anti-patterns directly in your browser. Open DevTools on any page and overlays instantly highlight issues, from AI-generated design tells to accessibility and quality problems.
|
||||||
|
|
||||||
|
WHAT IT DETECTS
|
||||||
|
|
||||||
|
AI slop (design tells that scream "AI made this"):
|
||||||
|
- Side-tab accent borders
|
||||||
|
- Gradient text on headings
|
||||||
|
- Purple/violet AI color palettes
|
||||||
|
- Nested cards, monotonous spacing
|
||||||
|
- Bounce/elastic easing
|
||||||
|
- Dark mode with glowing accents
|
||||||
|
- Overused fonts, flat type hierarchy
|
||||||
|
|
||||||
|
Quality issues (general design and accessibility):
|
||||||
|
- Low contrast text (WCAG AA)
|
||||||
|
- Cramped padding, tight line height
|
||||||
|
- Skipped heading levels
|
||||||
|
- Line length too long
|
||||||
|
- Tiny body text, justified text
|
||||||
|
- Layout property animations
|
||||||
|
|
||||||
|
HOW IT WORKS
|
||||||
|
|
||||||
|
1. Install the extension
|
||||||
|
2. Open DevTools on any page (Cmd+Opt+I / F12)
|
||||||
|
3. Overlays appear automatically, highlighting issues
|
||||||
|
4. Click the "Impeccable" panel tab for a structured list of all findings
|
||||||
|
5. Click any finding to jump to the element in the Elements panel
|
||||||
|
|
||||||
|
FEATURES
|
||||||
|
|
||||||
|
- Auto-scans when DevTools opens, no manual step needed
|
||||||
|
- Grouped findings: AI tells vs. quality issues
|
||||||
|
- Click-to-inspect: jump from a finding to the element
|
||||||
|
- Toggle overlays on/off from the panel or toolbar popup
|
||||||
|
- Per-rule settings: disable detections you don't care about
|
||||||
|
- Re-scans on navigation, including SPA route changes
|
||||||
|
- Works on any website
|
||||||
|
- Runs 100% locally, no data sent anywhere
|
||||||
|
|
||||||
|
Open source at https://github.com/pbakaus/impeccable
|
||||||
|
|
||||||
|
## Category
|
||||||
|
Developer Tools
|
||||||
|
|
||||||
|
## Language
|
||||||
|
English
|
||||||
|
|
||||||
|
## Privacy policy URL
|
||||||
|
https://impeccable.style/privacy
|
||||||
|
|
||||||
|
## Single purpose description
|
||||||
|
Detects and highlights UI anti-patterns (AI-generated design tells and general quality issues) on any web page.
|
||||||
@@ -13,7 +13,7 @@ const panelPorts = new Map();
|
|||||||
|
|
||||||
function getState(tabId) {
|
function getState(tabId) {
|
||||||
if (!tabState.has(tabId)) {
|
if (!tabState.has(tabId)) {
|
||||||
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false });
|
tabState.set(tabId, { findings: [], overlaysVisible: true, injected: false, csInjected: false });
|
||||||
}
|
}
|
||||||
return tabState.get(tabId);
|
return tabState.get(tabId);
|
||||||
}
|
}
|
||||||
@@ -35,17 +35,47 @@ function notifyPanels(tabId, message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDisabledRules() {
|
async function getSettings() {
|
||||||
const result = await chrome.storage.sync.get({ disabledRules: [] });
|
return chrome.storage.sync.get({
|
||||||
return result.disabledRules;
|
disabledRules: [],
|
||||||
|
lineLengthMode: 'strict', // 'strict' = 80, 'lax' = 120
|
||||||
|
spotlightBlur: true, // dim/blur the page on hover-highlight
|
||||||
|
autoScan: 'panel', // 'panel' = scan when Impeccable UI opens, 'devtools' = scan when DevTools opens
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildScanConfig() {
|
async function buildScanConfig() {
|
||||||
const disabledRules = await getDisabledRules();
|
const { disabledRules, lineLengthMode, spotlightBlur } = await getSettings();
|
||||||
return disabledRules.length ? { disabledRules } : null;
|
const config = {};
|
||||||
|
if (disabledRules.length) config.disabledRules = disabledRules;
|
||||||
|
config.lineLengthMax = lineLengthMode === 'lax' ? 120 : 80;
|
||||||
|
config.spotlightBlur = spotlightBlur;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the content script on-demand. We removed the static content_scripts entry to
|
||||||
|
// minimize the always-on footprint; the script is only loaded when the user explicitly
|
||||||
|
// engages with the extension (DevTools panel/sidebar opened, popup scan, etc).
|
||||||
|
async function ensureContentScriptInjected(tabId) {
|
||||||
|
const state = getState(tabId);
|
||||||
|
if (state.csInjected) return true;
|
||||||
|
try {
|
||||||
|
await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
files: ['content/content-script.js'],
|
||||||
|
injectImmediately: true,
|
||||||
|
});
|
||||||
|
state.csInjected = true;
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// Common cause: chrome:// pages, the web store, or other restricted URLs
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendScanToTab(tabId) {
|
async function sendScanToTab(tabId) {
|
||||||
|
const ok = await ensureContentScriptInjected(tabId);
|
||||||
|
if (!ok) return;
|
||||||
const config = await buildScanConfig();
|
const config = await buildScanConfig();
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
|
chrome.tabs.sendMessage(tabId, { action: 'scan', config }).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -75,6 +105,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else if (msg.action === 'page-pointer-active' && tabId) {
|
||||||
|
notifyPanels(tabId, { action: 'page-pointer-active' });
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
else if (msg.action === 'overlays-toggled' && tabId) {
|
else if (msg.action === 'overlays-toggled' && tabId) {
|
||||||
const state = getState(tabId);
|
const state = getState(tabId);
|
||||||
state.overlaysVisible = msg.visible;
|
state.overlaysVisible = msg.visible;
|
||||||
@@ -115,6 +150,23 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// Track which tabs have DevTools open (via the devtools.js lifecycle port)
|
// Track which tabs have DevTools open (via the devtools.js lifecycle port)
|
||||||
const devtoolsTabs = new Set();
|
const devtoolsTabs = new Set();
|
||||||
|
|
||||||
|
async function tearDownTab(tabId) {
|
||||||
|
devtoolsTabs.delete(tabId);
|
||||||
|
// Send the remove command and await it — this keeps the SW alive long enough
|
||||||
|
// to actually deliver the message (setTimeout doesn't survive SW termination in MV3).
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tabId, { action: 'remove' });
|
||||||
|
} catch { /* tab might be closed or content script gone */ }
|
||||||
|
const state = tabState.get(tabId);
|
||||||
|
if (state) {
|
||||||
|
state.findings = [];
|
||||||
|
state.injected = false;
|
||||||
|
state.csInjected = false;
|
||||||
|
}
|
||||||
|
updateBadge(tabId);
|
||||||
|
panelPorts.delete(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle long-lived connections from DevTools pages and panels
|
// Handle long-lived connections from DevTools pages and panels
|
||||||
chrome.runtime.onConnect.addListener((port) => {
|
chrome.runtime.onConnect.addListener((port) => {
|
||||||
// Lifecycle port from devtools.js -- tracks DevTools open/close
|
// Lifecycle port from devtools.js -- tracks DevTools open/close
|
||||||
@@ -124,19 +176,13 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||||||
|
|
||||||
port.onMessage.addListener((msg) => {
|
port.onMessage.addListener((msg) => {
|
||||||
if (msg.action === 'scan') sendScanToTab(tabId);
|
if (msg.action === 'scan') sendScanToTab(tabId);
|
||||||
|
// 'ping' is just a keepalive; no action needed
|
||||||
});
|
});
|
||||||
|
|
||||||
port.onDisconnect.addListener(() => {
|
port.onDisconnect.addListener(() => {
|
||||||
devtoolsTabs.delete(tabId);
|
// Tear down immediately — defer with setTimeout doesn't work reliably in MV3
|
||||||
// DevTools closed -- remove overlays and clear state
|
// because the SW can be terminated before the timer fires.
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'remove' }).catch(() => {});
|
tearDownTab(tabId);
|
||||||
const state = tabState.get(tabId);
|
|
||||||
if (state) {
|
|
||||||
state.findings = [];
|
|
||||||
state.injected = false;
|
|
||||||
}
|
|
||||||
updateBadge(tabId);
|
|
||||||
panelPorts.delete(tabId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +206,10 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||||||
sendScanToTab(tabId);
|
sendScanToTab(tabId);
|
||||||
} else if (msg.action === 'toggle-overlays') {
|
} else if (msg.action === 'toggle-overlays') {
|
||||||
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
|
chrome.tabs.sendMessage(tabId, { action: 'toggle-overlays' }).catch(() => {});
|
||||||
|
} else if (msg.action === 'highlight') {
|
||||||
|
chrome.tabs.sendMessage(tabId, { action: 'highlight', selector: msg.selector }).catch(() => {});
|
||||||
|
} else if (msg.action === 'unhighlight') {
|
||||||
|
chrome.tabs.sendMessage(tabId, { action: 'unhighlight' }).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -168,19 +218,40 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||||||
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
|
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sidebar pane port (Elements panel sidebar) -- receives findings updates.
|
||||||
|
// Connecting the sidebar is a strong signal of "user engaged with Impeccable"
|
||||||
|
// so we trigger a scan if no findings exist yet (matches the panel port behavior).
|
||||||
|
if (port.name.startsWith('impeccable-sidebar-')) {
|
||||||
|
const tabId = parseInt(port.name.replace('impeccable-sidebar-', ''), 10);
|
||||||
|
if (!panelPorts.has(tabId)) panelPorts.set(tabId, new Set());
|
||||||
|
panelPorts.get(tabId).add(port);
|
||||||
|
|
||||||
|
const state = getState(tabId);
|
||||||
|
port.postMessage({ action: 'state', ...state });
|
||||||
|
if (!state.findings.length) sendScanToTab(tabId);
|
||||||
|
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
panelPorts.get(tabId)?.delete(port);
|
||||||
|
if (panelPorts.get(tabId)?.size === 0) panelPorts.delete(tabId);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-scan on navigation (only if DevTools is open for that tab)
|
// Re-scan on navigation (only if DevTools is open AND user was actively scanning)
|
||||||
chrome.webNavigation?.onCompleted?.addListener((details) => {
|
chrome.webNavigation?.onCompleted?.addListener((details) => {
|
||||||
if (details.frameId !== 0) return;
|
if (details.frameId !== 0) return;
|
||||||
if (!devtoolsTabs.has(details.tabId)) return;
|
if (!devtoolsTabs.has(details.tabId)) return;
|
||||||
const state = tabState.get(details.tabId);
|
const state = tabState.get(details.tabId);
|
||||||
if (state) {
|
if (!state) return;
|
||||||
state.findings = [];
|
// Only re-scan if the user has actively engaged (had findings or injected previously)
|
||||||
state.injected = false;
|
const wasActive = state.injected || state.findings.length > 0;
|
||||||
updateBadge(details.tabId);
|
state.findings = [];
|
||||||
notifyPanels(details.tabId, { action: 'navigated' });
|
state.injected = false;
|
||||||
// Re-inject and scan after a short delay for the page to settle
|
state.csInjected = false; // page reload destroys the content script
|
||||||
|
updateBadge(details.tabId);
|
||||||
|
notifyPanels(details.tabId, { action: 'navigated' });
|
||||||
|
if (wasActive) {
|
||||||
setTimeout(() => sendScanToTab(details.tabId), 300);
|
setTimeout(() => sendScanToTab(details.tabId), 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,95 +4,122 @@
|
|||||||
* Bridges between the extension messaging system and the page-context detector.
|
* Bridges between the extension messaging system and the page-context detector.
|
||||||
* The detector must run in page context (not isolated world) because it needs
|
* The detector must run in page context (not isolated world) because it needs
|
||||||
* access to getComputedStyle, document.styleSheets.cssRules, etc.
|
* access to getComputedStyle, document.styleSheets.cssRules, etc.
|
||||||
|
*
|
||||||
|
* Wrapped in an IIFE with an idempotency flag so re-injection (via
|
||||||
|
* chrome.scripting.executeScript) is a no-op and doesn't cause:
|
||||||
|
* - SyntaxError: Identifier 'foo' has already been declared
|
||||||
|
* - Duplicate event listeners accumulating over time
|
||||||
*/
|
*/
|
||||||
|
(function () {
|
||||||
|
if (window.__IMPECCABLE_CS_LOADED__) return;
|
||||||
|
window.__IMPECCABLE_CS_LOADED__ = true;
|
||||||
|
|
||||||
let injected = false;
|
let injected = false;
|
||||||
let pendingScan = false;
|
let pendingScan = false;
|
||||||
let scanConfig = null;
|
let scanConfig = null;
|
||||||
|
|
||||||
// Listen for commands from the service worker
|
// Listen for commands from the service worker
|
||||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.action === 'scan') {
|
if (msg.action === 'scan') {
|
||||||
scanConfig = msg.config || null;
|
scanConfig = msg.config || null;
|
||||||
injectAndScan();
|
injectAndScan();
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
} else if (msg.action === 'toggle-overlays') {
|
} else if (msg.action === 'toggle-overlays') {
|
||||||
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
|
window.postMessage({ source: 'impeccable-command', action: 'toggle-overlays' }, '*');
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
} else if (msg.action === 'remove') {
|
} else if (msg.action === 'remove') {
|
||||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||||
injected = false;
|
injected = false;
|
||||||
sendResponse({ ok: true });
|
sendResponse({ ok: true });
|
||||||
}
|
} else if (msg.action === 'highlight') {
|
||||||
return true;
|
window.postMessage({ source: 'impeccable-command', action: 'highlight', selector: msg.selector }, '*');
|
||||||
});
|
sendResponse({ ok: true });
|
||||||
|
} else if (msg.action === 'unhighlight') {
|
||||||
|
window.postMessage({ source: 'impeccable-command', action: 'unhighlight' }, '*');
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for results and state changes from the detector in page context
|
// Listen for results and state changes from the detector in page context
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (e.source !== window || !e.data) return;
|
if (e.source !== window || !e.data) return;
|
||||||
|
|
||||||
if (e.data.source === 'impeccable-results') {
|
if (e.data.source === 'impeccable-results') {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
action: 'findings',
|
action: 'findings',
|
||||||
findings: e.data.findings,
|
findings: e.data.findings,
|
||||||
count: e.data.count,
|
count: e.data.count,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.data.source === 'impeccable-overlays-toggled') {
|
if (e.data.source === 'impeccable-overlays-toggled') {
|
||||||
chrome.runtime.sendMessage({
|
chrome.runtime.sendMessage({
|
||||||
action: 'overlays-toggled',
|
action: 'overlays-toggled',
|
||||||
visible: e.data.visible,
|
visible: e.data.visible,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.data.source === 'impeccable-ready') {
|
if (e.data.source === 'impeccable-ready') {
|
||||||
injected = true;
|
injected = true;
|
||||||
if (pendingScan) {
|
if (pendingScan) {
|
||||||
pendingScan = false;
|
pendingScan = false;
|
||||||
sendScanCommand();
|
sendScanCommand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward "page is active" signal to the extension when the cursor moves over the page.
|
||||||
|
// This is the reliable way to know the user has left the DevTools panel — the panel's
|
||||||
|
// own pointerleave/mouseleave events are unreliable on fast cursor movement.
|
||||||
|
let lastPageActive = 0;
|
||||||
|
document.addEventListener('pointermove', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPageActive < 150) return; // throttle
|
||||||
|
lastPageActive = now;
|
||||||
|
chrome.runtime.sendMessage({ action: 'page-pointer-active' }).catch(() => {});
|
||||||
|
}, { passive: true, capture: true });
|
||||||
|
|
||||||
|
// SPA navigation detection (pushState/replaceState don't fire events, but
|
||||||
|
// popstate and hashchange cover back/forward and hash navigation)
|
||||||
|
let lastUrl = location.href;
|
||||||
|
function onPossibleNavigation() {
|
||||||
|
if (location.href === lastUrl) return;
|
||||||
|
lastUrl = location.href;
|
||||||
|
if (injected) {
|
||||||
|
// Detector is still loaded in page context, just re-scan after DOM settles
|
||||||
|
setTimeout(sendScanCommand, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
window.addEventListener('popstate', onPossibleNavigation);
|
||||||
|
window.addEventListener('hashchange', onPossibleNavigation);
|
||||||
|
|
||||||
// SPA navigation detection (pushState/replaceState don't fire events, but
|
function sendScanCommand() {
|
||||||
// popstate and hashchange cover back/forward and hash navigation)
|
const msg = { source: 'impeccable-command', action: 'scan' };
|
||||||
let lastUrl = location.href;
|
if (scanConfig) msg.config = scanConfig;
|
||||||
function onPossibleNavigation() {
|
window.postMessage(msg, '*');
|
||||||
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)
|
function injectAndScan() {
|
||||||
document.documentElement.dataset.impeccableExtension = 'true';
|
if (injected) {
|
||||||
|
sendScanCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Inject the detector script into page context
|
// Set the extension flag via a data attribute (CSP-safe: content scripts share the DOM)
|
||||||
const script = document.createElement('script');
|
document.documentElement.dataset.impeccableExtension = 'true';
|
||||||
script.src = chrome.runtime.getURL('detector/detect.js');
|
|
||||||
pendingScan = true;
|
// Inject the detector script into page context
|
||||||
script.onload = () => script.remove();
|
const script = document.createElement('script');
|
||||||
script.onerror = () => {
|
script.src = chrome.runtime.getURL('detector/detect.js');
|
||||||
script.remove();
|
script.dataset.impeccableExtension = 'true';
|
||||||
// Fallback: use chrome.scripting.executeScript for strict CSP pages
|
pendingScan = true;
|
||||||
chrome.runtime.sendMessage({ action: 'inject-fallback' });
|
script.onload = () => script.remove();
|
||||||
};
|
script.onerror = () => {
|
||||||
(document.head || document.documentElement).appendChild(script);
|
script.remove();
|
||||||
}
|
// Fallback: use chrome.scripting.executeScript for strict CSP pages
|
||||||
|
chrome.runtime.sendMessage({ action: 'inject-fallback' });
|
||||||
|
};
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -12,10 +12,39 @@ chrome.devtools.panels.create(
|
|||||||
'devtools/panel.html'
|
'devtools/panel.html'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Connect a lifecycle port so the service worker knows when DevTools closes
|
// Sidebar pane in the Elements panel: shows findings for the currently selected element
|
||||||
const port = chrome.runtime.connect({
|
chrome.devtools.panels.elements.createSidebarPane('Impeccable', (sidebar) => {
|
||||||
name: `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`,
|
sidebar.setPage('devtools/sidebar.html');
|
||||||
|
sidebar.setHeight('200px');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scan when DevTools opens (regardless of which panel is active).
|
// Lifecycle port to the service worker. Auto-reconnects if the SW gets terminated
|
||||||
port.postMessage({ action: 'scan' });
|
// (which can happen in MV3 after ~30s of inactivity, especially when the browser is unfocused).
|
||||||
|
const portName = `impeccable-devtools-${chrome.devtools.inspectedWindow.tabId}`;
|
||||||
|
let lifecyclePort = null;
|
||||||
|
let firstConnect = true;
|
||||||
|
function connectLifecycle() {
|
||||||
|
lifecyclePort = chrome.runtime.connect({ name: portName });
|
||||||
|
// On the very first connection, decide whether to auto-scan based on the user's setting.
|
||||||
|
// Default ('panel'): wait until the user opens the Impeccable panel or sidebar.
|
||||||
|
// Opt-in ('devtools'): scan immediately when DevTools opens.
|
||||||
|
if (firstConnect) {
|
||||||
|
firstConnect = false;
|
||||||
|
chrome.storage.sync.get({ autoScan: 'panel' }, (settings) => {
|
||||||
|
if (settings.autoScan === 'devtools') {
|
||||||
|
try { lifecyclePort?.postMessage({ action: 'scan' }); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
lifecyclePort.onDisconnect.addListener(() => {
|
||||||
|
lifecyclePort = null;
|
||||||
|
// Reconnect on the next tick so the SW sees a fresh connection
|
||||||
|
setTimeout(connectLifecycle, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connectLifecycle();
|
||||||
|
|
||||||
|
// Heartbeat to keep the SW alive
|
||||||
|
setInterval(() => {
|
||||||
|
try { lifecyclePort?.postMessage({ action: 'ping' }); } catch {}
|
||||||
|
}, 20000);
|
||||||
|
|||||||
+167
-12
@@ -113,12 +113,8 @@ h1 {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-btn.active {
|
.tool-btn.inactive {
|
||||||
color: var(--accent);
|
opacity: 0.4;
|
||||||
}
|
|
||||||
|
|
||||||
.tool-btn.active:hover {
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Findings */
|
/* Findings */
|
||||||
@@ -192,6 +188,50 @@ h1 {
|
|||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finding-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-row .finding-selector {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-copy {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-item:hover .finding-copy {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-copy:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-copy.copied {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn.copied {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.finding-selector {
|
.finding-selector {
|
||||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -222,13 +262,35 @@ h1 {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page-level findings */
|
/* Finding tags (page-level, hidden, etc.) */
|
||||||
.page-level-tag {
|
.finding-tag {
|
||||||
font-size: 10px;
|
display: inline-block;
|
||||||
font-weight: 500;
|
font-size: 9px;
|
||||||
color: var(--accent-dim);
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-page {
|
||||||
|
color: var(--accent-dim);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-hidden {
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-item.is-hidden {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-item.is-hidden:hover {
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
@@ -315,10 +377,103 @@ h1 {
|
|||||||
padding: 8px 8px 6px;
|
padding: 8px 8px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#settings-list {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1px 12px;
|
gap: 1px 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settings-list .settings-header {
|
||||||
|
padding: 8px 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-segmented button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-segmented button:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-segmented button:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-segmented button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch-track {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch-track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch input:checked + .setting-switch-track {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-switch input:checked + .setting-switch-track::before {
|
||||||
|
transform: translateX(12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-rule {
|
.setting-rule {
|
||||||
|
|||||||
@@ -12,10 +12,13 @@
|
|||||||
<span class="badge" id="badge">0</span>
|
<span class="badge" id="badge">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
|
<button class="tool-btn" id="btn-copy-all" title="Copy all findings">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M11 1H3a2 2 0 0 0-2 2v10h2V3h8V1zm3 3H7a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 11H7V6h7v9z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
<button class="tool-btn" id="btn-rescan" title="Re-scan page">
|
<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>
|
<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>
|
||||||
<button class="tool-btn active" id="btn-toggle" title="Toggle overlays">
|
<button class="tool-btn" id="btn-toggle" title="Toggle overlays">
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M8 3C4.36 3 1.26 5.28 0 8.5c1.26 3.22 4.36 5.5 8 5.5s6.74-2.28 8-5.5C14.74 5.28 11.64 3 8 3zm0 9.17c-2.58 0-4.67-2.09-4.67-4.67S5.42 2.83 8 2.83s4.67 2.09 4.67 4.67S10.58 12.17 8 12.17zM8 5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7z" fill="currentColor"/></svg>
|
<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>
|
||||||
<button class="tool-btn" id="btn-settings" title="Settings">
|
<button class="tool-btn" id="btn-settings" title="Settings">
|
||||||
@@ -25,7 +28,28 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="settings-container" style="display: none">
|
<div id="settings-container" style="display: none">
|
||||||
<div class="settings-header">Rules</div>
|
<div class="settings-header">Preferences</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Auto-scan</span>
|
||||||
|
<div class="setting-segmented" id="auto-scan-mode">
|
||||||
|
<button data-value="panel">When panel opens</button>
|
||||||
|
<button data-value="devtools">When DevTools opens</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Line length</span>
|
||||||
|
<div class="setting-segmented" id="line-length-mode">
|
||||||
|
<button data-value="strict">Strict (80)</button>
|
||||||
|
<button data-value="lax">Lax (120)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span class="setting-label">Highlight blur</span>
|
||||||
|
<label class="setting-switch">
|
||||||
|
<input type="checkbox" id="spotlight-blur-toggle">
|
||||||
|
<span class="setting-switch-track"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div id="settings-list"></div>
|
<div id="settings-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+225
-23
@@ -11,13 +11,34 @@ if (chrome.devtools.panels.themeName === 'dark') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabId = chrome.devtools.inspectedWindow.tabId;
|
const tabId = chrome.devtools.inspectedWindow.tabId;
|
||||||
const port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
|
|
||||||
|
// Auto-reconnecting port. Service workers in MV3 can be terminated after ~30s of
|
||||||
|
// inactivity (especially when the browser window is unfocused). When they restart,
|
||||||
|
// the existing port becomes invalid. We recreate it lazily on the next use.
|
||||||
|
let port = null;
|
||||||
|
function getPort() {
|
||||||
|
if (port) return port;
|
||||||
|
port = chrome.runtime.connect({ name: `impeccable-panel-${tabId}` });
|
||||||
|
port.onMessage.addListener(handlePortMessage);
|
||||||
|
port.onDisconnect.addListener(() => { port = null; });
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
function postToPort(msg) {
|
||||||
|
try {
|
||||||
|
getPort().postMessage(msg);
|
||||||
|
} catch {
|
||||||
|
// Port died mid-call. Drop it and try once more with a fresh port.
|
||||||
|
port = null;
|
||||||
|
try { getPort().postMessage(msg); } catch { /* give up silently */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const badge = document.getElementById('badge');
|
const badge = document.getElementById('badge');
|
||||||
const container = document.getElementById('findings-container');
|
const container = document.getElementById('findings-container');
|
||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
const btnRescan = document.getElementById('btn-rescan');
|
const btnRescan = document.getElementById('btn-rescan');
|
||||||
const btnToggle = document.getElementById('btn-toggle');
|
const btnToggle = document.getElementById('btn-toggle');
|
||||||
|
const btnCopyAll = document.getElementById('btn-copy-all');
|
||||||
const settingsContainer = document.getElementById('settings-container');
|
const settingsContainer = document.getElementById('settings-container');
|
||||||
const settingsList = document.getElementById('settings-list');
|
const settingsList = document.getElementById('settings-list');
|
||||||
const btnSettings = document.getElementById('btn-settings');
|
const btnSettings = document.getElementById('btn-settings');
|
||||||
@@ -25,6 +46,7 @@ const btnSettings = document.getElementById('btn-settings');
|
|||||||
let overlaysVisible = true;
|
let overlaysVisible = true;
|
||||||
let allAntipatterns = [];
|
let allAntipatterns = [];
|
||||||
let disabledRules = [];
|
let disabledRules = [];
|
||||||
|
let currentFindings = [];
|
||||||
|
|
||||||
// Load antipatterns list and disabled rules
|
// Load antipatterns list and disabled rules
|
||||||
async function initSettings() {
|
async function initSettings() {
|
||||||
@@ -33,28 +55,100 @@ async function initSettings() {
|
|||||||
allAntipatterns = await resp.json();
|
allAntipatterns = await resp.json();
|
||||||
} catch { allAntipatterns = []; }
|
} catch { allAntipatterns = []; }
|
||||||
|
|
||||||
const stored = await chrome.storage.sync.get({ disabledRules: [] });
|
const stored = await chrome.storage.sync.get({
|
||||||
|
disabledRules: [],
|
||||||
|
lineLengthMode: 'strict',
|
||||||
|
spotlightBlur: true,
|
||||||
|
autoScan: 'panel',
|
||||||
|
});
|
||||||
disabledRules = stored.disabledRules;
|
disabledRules = stored.disabledRules;
|
||||||
renderSettings();
|
renderSettings();
|
||||||
|
initLineLengthControl(stored.lineLengthMode);
|
||||||
|
initSpotlightBlurToggle(stored.spotlightBlur);
|
||||||
|
initAutoScanControl(stored.autoScan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAutoScanControl(currentMode) {
|
||||||
|
const group = document.getElementById('auto-scan-mode');
|
||||||
|
if (!group) return;
|
||||||
|
for (const btn of group.querySelectorAll('button')) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.value === currentMode);
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const mode = btn.dataset.value;
|
||||||
|
for (const b of group.querySelectorAll('button')) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ autoScan: mode });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLineLengthControl(currentMode) {
|
||||||
|
const group = document.getElementById('line-length-mode');
|
||||||
|
if (!group) return;
|
||||||
|
for (const btn of group.querySelectorAll('button')) {
|
||||||
|
btn.classList.toggle('active', btn.dataset.value === currentMode);
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const mode = btn.dataset.value;
|
||||||
|
for (const b of group.querySelectorAll('button')) {
|
||||||
|
b.classList.toggle('active', b === btn);
|
||||||
|
}
|
||||||
|
await chrome.storage.sync.set({ lineLengthMode: mode });
|
||||||
|
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSpotlightBlurToggle(currentValue) {
|
||||||
|
const cb = document.getElementById('spotlight-blur-toggle');
|
||||||
|
if (!cb) return;
|
||||||
|
cb.checked = currentValue;
|
||||||
|
cb.addEventListener('change', async () => {
|
||||||
|
await chrome.storage.sync.set({ spotlightBlur: cb.checked });
|
||||||
|
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
settingsList.innerHTML = '';
|
settingsList.innerHTML = '';
|
||||||
|
|
||||||
|
const categories = {
|
||||||
|
slop: { label: 'AI tells', items: [] },
|
||||||
|
quality: { label: 'Quality', items: [] },
|
||||||
|
};
|
||||||
for (const ap of allAntipatterns) {
|
for (const ap of allAntipatterns) {
|
||||||
const label = document.createElement('label');
|
const cat = ap.category || 'quality';
|
||||||
label.className = 'setting-rule';
|
(categories[cat] || categories.quality).items.push(ap);
|
||||||
|
}
|
||||||
|
|
||||||
const checkbox = document.createElement('input');
|
for (const [, group] of Object.entries(categories)) {
|
||||||
checkbox.type = 'checkbox';
|
if (!group.items.length) continue;
|
||||||
checkbox.checked = !disabledRules.includes(ap.id);
|
|
||||||
checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
|
|
||||||
|
|
||||||
const text = document.createElement('span');
|
const header = document.createElement('div');
|
||||||
text.textContent = ap.name;
|
header.className = 'settings-header';
|
||||||
|
header.textContent = group.label;
|
||||||
|
settingsList.appendChild(header);
|
||||||
|
|
||||||
label.appendChild(checkbox);
|
const grid = document.createElement('div');
|
||||||
label.appendChild(text);
|
grid.className = 'settings-grid';
|
||||||
settingsList.appendChild(label);
|
|
||||||
|
for (const ap of group.items) {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.className = 'setting-rule';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.checked = !disabledRules.includes(ap.id);
|
||||||
|
checkbox.addEventListener('change', () => toggleRule(ap.id, checkbox.checked));
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = ap.name;
|
||||||
|
|
||||||
|
label.appendChild(checkbox);
|
||||||
|
label.appendChild(text);
|
||||||
|
grid.appendChild(label);
|
||||||
|
}
|
||||||
|
settingsList.appendChild(grid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +162,13 @@ async function toggleRule(ruleId, enabled) {
|
|||||||
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
|
chrome.runtime.sendMessage({ action: 'disabled-rules-changed' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for messages from the service worker
|
// Listen for messages from the service worker (called by getPort() on each new connection)
|
||||||
port.onMessage.addListener((msg) => {
|
function handlePortMessage(msg) {
|
||||||
|
if (msg.action === 'page-pointer-active') {
|
||||||
|
// Cursor is active on the page → user has left the panel
|
||||||
|
setHoveredItem(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.action === 'findings' || msg.action === 'state') {
|
if (msg.action === 'findings' || msg.action === 'state') {
|
||||||
renderFindings(msg.findings || []);
|
renderFindings(msg.findings || []);
|
||||||
if (msg.overlaysVisible !== undefined) {
|
if (msg.overlaysVisible !== undefined) {
|
||||||
@@ -84,16 +183,23 @@ port.onMessage.addListener((msg) => {
|
|||||||
if (msg.action === 'navigated') {
|
if (msg.action === 'navigated') {
|
||||||
showScanning();
|
showScanning();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// Initial connection
|
||||||
|
getPort();
|
||||||
|
|
||||||
|
// Heartbeat to keep the MV3 service worker alive while the panel is open.
|
||||||
|
// SWs can be terminated after ~30s of inactivity, especially when the browser is unfocused.
|
||||||
|
setInterval(() => postToPort({ action: 'ping' }), 20000);
|
||||||
|
|
||||||
// Controls
|
// Controls
|
||||||
btnRescan.addEventListener('click', () => {
|
btnRescan.addEventListener('click', () => {
|
||||||
showScanning();
|
showScanning();
|
||||||
port.postMessage({ action: 'scan' });
|
postToPort({ action: 'scan' });
|
||||||
});
|
});
|
||||||
|
|
||||||
btnToggle.addEventListener('click', () => {
|
btnToggle.addEventListener('click', () => {
|
||||||
port.postMessage({ action: 'toggle-overlays' });
|
postToPort({ action: 'toggle-overlays' });
|
||||||
});
|
});
|
||||||
|
|
||||||
btnSettings.addEventListener('click', () => {
|
btnSettings.addEventListener('click', () => {
|
||||||
@@ -103,8 +209,8 @@ btnSettings.addEventListener('click', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateToggleButton() {
|
function updateToggleButton() {
|
||||||
btnToggle.classList.toggle('active', overlaysVisible);
|
|
||||||
btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays';
|
btnToggle.title = overlaysVisible ? 'Hide overlays' : 'Show overlays';
|
||||||
|
btnToggle.classList.toggle('inactive', !overlaysVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function showScanning() {
|
function showScanning() {
|
||||||
@@ -115,7 +221,86 @@ function showScanning() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFindingsForCopy(findings) {
|
||||||
|
if (!findings.length) return 'No anti-patterns detected.';
|
||||||
|
const lines = ['Impeccable detected the following UI anti-patterns:', ''];
|
||||||
|
const groups = { slop: [], quality: [] };
|
||||||
|
for (const item of findings) {
|
||||||
|
for (const f of item.findings) {
|
||||||
|
const cat = f.category || 'quality';
|
||||||
|
groups[cat].push({ ...f, selector: item.selector, isPageLevel: item.isPageLevel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groups.slop.length) {
|
||||||
|
lines.push('## AI tells');
|
||||||
|
for (const f of groups.slop) {
|
||||||
|
lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (groups.quality.length) {
|
||||||
|
lines.push('## Quality issues');
|
||||||
|
for (const f of groups.quality) {
|
||||||
|
lines.push(`- ${f.name} ${f.isPageLevel ? '(page-level)' : `at \`${f.selector}\``}: ${f.detail}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('Please fix these issues.');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSingleFindingForCopy(item, finding) {
|
||||||
|
const where = item.isPageLevel ? '(page-level)' : `at \`${item.selector}\``;
|
||||||
|
return `Impeccable detected: ${finding.name} ${where}\nDetail: ${finding.detail}\n\n${finding.description}\n\nPlease fix this.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text, btn) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (btn) {
|
||||||
|
const orig = btn.title;
|
||||||
|
btn.title = 'Copied!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.title = orig;
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Copy failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnCopyAll.addEventListener('click', () => {
|
||||||
|
copyToClipboard(formatFindingsForCopy(currentFindings), btnCopyAll);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delegated hover tracking on the findings container.
|
||||||
|
// Reliably handles cursor moving between items, into children, or out of the panel.
|
||||||
|
let currentHoverSelector = null;
|
||||||
|
function setHoveredItem(selector) {
|
||||||
|
if (selector === currentHoverSelector) return;
|
||||||
|
currentHoverSelector = selector;
|
||||||
|
if (selector) {
|
||||||
|
postToPort({ action: 'highlight', selector });
|
||||||
|
} else {
|
||||||
|
postToPort({ action: 'unhighlight' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('pointermove', (e) => {
|
||||||
|
const item = e.target.closest('.finding-item');
|
||||||
|
const selector = item && !item.classList.contains('is-hidden') ? item.dataset.selector || null : null;
|
||||||
|
setHoveredItem(selector);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slow-cursor fallbacks (these fire reliably for slow movements)
|
||||||
|
container.addEventListener('pointerleave', () => setHoveredItem(null));
|
||||||
|
window.addEventListener('blur', () => setHoveredItem(null));
|
||||||
|
|
||||||
|
|
||||||
function renderFindings(findings) {
|
function renderFindings(findings) {
|
||||||
|
currentFindings = findings;
|
||||||
if (!findings.length) {
|
if (!findings.length) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
container.appendChild(emptyState);
|
container.appendChild(emptyState);
|
||||||
@@ -145,6 +330,7 @@ function renderFindings(findings) {
|
|||||||
selector: item.selector,
|
selector: item.selector,
|
||||||
tagName: item.tagName,
|
tagName: item.tagName,
|
||||||
isPageLevel: item.isPageLevel,
|
isPageLevel: item.isPageLevel,
|
||||||
|
isHidden: item.isHidden,
|
||||||
detail: f.detail,
|
detail: f.detail,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -186,14 +372,30 @@ function renderFindings(findings) {
|
|||||||
|
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
const itemEl = document.createElement('div');
|
const itemEl = document.createElement('div');
|
||||||
itemEl.className = 'finding-item';
|
itemEl.className = 'finding-item' + (item.isHidden ? ' is-hidden' : '');
|
||||||
|
const tag = item.isPageLevel
|
||||||
|
? '<span class="finding-tag tag-page">page</span>'
|
||||||
|
: item.isHidden ? '<span class="finding-tag tag-hidden" title="Element is currently hidden on the page">hidden</span>' : '';
|
||||||
itemEl.innerHTML = `
|
itemEl.innerHTML = `
|
||||||
${item.isPageLevel ? '<span class="page-level-tag">page</span>' : ''}
|
${tag}
|
||||||
<span class="finding-selector">${escapeHtml(item.selector)}</span>
|
<div class="finding-row">
|
||||||
|
<span class="finding-selector">${escapeHtml(item.selector)}</span>
|
||||||
|
<button class="finding-copy" title="Copy this finding">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><path d="M11 1H3a2 2 0 0 0-2 2v10h2V3h8V1zm3 3H7a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 11H7V6h7v9z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span class="finding-detail">${escapeHtml(item.detail)}</span>
|
<span class="finding-detail">${escapeHtml(item.detail)}</span>
|
||||||
<span class="finding-description">${escapeHtml(group.description)}</span>`;
|
<span class="finding-description">${escapeHtml(group.description)}</span>`;
|
||||||
|
|
||||||
if (!item.isPageLevel) {
|
const copyBtn = itemEl.querySelector('.finding-copy');
|
||||||
|
const finding = { name: group.name, description: group.description, detail: item.detail };
|
||||||
|
copyBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copyToClipboard(formatSingleFindingForCopy(item, finding), copyBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item.isPageLevel && !item.isHidden) {
|
||||||
|
itemEl.dataset.selector = item.selector;
|
||||||
itemEl.addEventListener('click', () => inspectElement(item.selector));
|
itemEl.addEventListener('click', () => inspectElement(item.selector));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-dim: #666;
|
||||||
|
--text-faint: #999;
|
||||||
|
--accent: oklch(48% 0.25 350);
|
||||||
|
--rule: #e8e6e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark {
|
||||||
|
--bg: #1a1a1a;
|
||||||
|
--text: #f5f3ef;
|
||||||
|
--text-dim: #9a9590;
|
||||||
|
--text-faint: #666;
|
||||||
|
--accent: oklch(60% 0.25 350);
|
||||||
|
--rule: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty / no-findings states */
|
||||||
|
|
||||||
|
.state {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state strong {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Finding list */
|
||||||
|
|
||||||
|
.finding + .finding {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-name .marker {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-kind {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-detail {
|
||||||
|
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-description {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="sidebar.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sidebar-content">
|
||||||
|
<div class="state">Select an element to see Impeccable findings.</div>
|
||||||
|
</div>
|
||||||
|
<script src="sidebar.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Impeccable DevTools Extension - Elements Sidebar Pane
|
||||||
|
*
|
||||||
|
* Shows Impeccable findings for the currently selected element ($0) in the Elements panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (chrome.devtools.panels.themeName === 'dark') {
|
||||||
|
document.documentElement.classList.add('theme-dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabId = chrome.devtools.inspectedWindow.tabId;
|
||||||
|
const content = document.getElementById('sidebar-content');
|
||||||
|
let currentFindings = [];
|
||||||
|
|
||||||
|
// Auto-reconnecting port (service worker may restart in MV3)
|
||||||
|
let port = null;
|
||||||
|
function getPort() {
|
||||||
|
if (port) return port;
|
||||||
|
port = chrome.runtime.connect({ name: `impeccable-sidebar-${tabId}` });
|
||||||
|
port.onMessage.addListener((msg) => {
|
||||||
|
if (msg.action === 'findings' || msg.action === 'state') {
|
||||||
|
currentFindings = msg.findings || [];
|
||||||
|
refreshForCurrentSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port.onDisconnect.addListener(() => { port = null; });
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
getPort();
|
||||||
|
|
||||||
|
chrome.devtools.panels.elements.onSelectionChanged.addListener(refreshForCurrentSelection);
|
||||||
|
|
||||||
|
function refreshForCurrentSelection() {
|
||||||
|
if (!currentFindings.length) {
|
||||||
|
renderEmpty('No findings on this page yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect non-page-level selectors and ask the inspected window which one matches $0
|
||||||
|
const selectors = [];
|
||||||
|
for (const item of currentFindings) {
|
||||||
|
if (item.isPageLevel || item.isHidden) continue;
|
||||||
|
selectors.push(item.selector);
|
||||||
|
}
|
||||||
|
if (!selectors.length) {
|
||||||
|
renderEmpty('No element-level findings on this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = `(function() {
|
||||||
|
var sels = ${JSON.stringify(selectors)};
|
||||||
|
var matched = [];
|
||||||
|
for (var i = 0; i < sels.length; i++) {
|
||||||
|
try { if (document.querySelector(sels[i]) === $0) matched.push(sels[i]); } catch (e) {}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
})()`;
|
||||||
|
|
||||||
|
chrome.devtools.inspectedWindow.eval(code, (matched) => {
|
||||||
|
if (!matched || !matched.length) {
|
||||||
|
renderNoFindings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = currentFindings.filter(item => matched.includes(item.selector));
|
||||||
|
render(items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmpty(text) {
|
||||||
|
content.innerHTML = `<div class="state">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoFindings() {
|
||||||
|
content.innerHTML = `<div class="state"><strong>Clean.</strong> No anti-patterns on this element.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(items) {
|
||||||
|
const html = [];
|
||||||
|
for (const item of items) {
|
||||||
|
for (const f of item.findings) {
|
||||||
|
const isSlop = f.category === 'slop';
|
||||||
|
const marker = isSlop ? '<span class="marker">\u2726</span>' : '';
|
||||||
|
const kind = isSlop ? 'AI tell' : 'Quality';
|
||||||
|
html.push(`
|
||||||
|
<div class="finding">
|
||||||
|
<div class="finding-header">
|
||||||
|
<span class="finding-name">${marker}${escapeHtml(f.name)}</span>
|
||||||
|
<span class="finding-kind">${kind}</span>
|
||||||
|
</div>
|
||||||
|
<div class="finding-detail">${escapeHtml(f.detail)}</div>
|
||||||
|
<div class="finding-description">${escapeHtml(f.description)}</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.innerHTML = html.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -8,13 +8,6 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background/service-worker.js"
|
"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",
|
"devtools_page": "devtools/devtools.html",
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "popup/popup.html",
|
"default_popup": "popup/popup.html",
|
||||||
|
|||||||
+12
-1
@@ -18,7 +18,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<a href="/" class="back">← Back to impeccable.style</a>
|
<a href="/" class="back">← Back to impeccable.style</a>
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
<p class="updated">Last updated: March 24, 2026</p>
|
<p class="updated">Last updated: April 6, 2026</p>
|
||||||
|
|
||||||
<h2>What Impeccable is</h2>
|
<h2>What Impeccable is</h2>
|
||||||
<p>Impeccable is an open-source collection of agent skills (text files) that run locally in your AI coding tool. The skills themselves collect no data, make no network requests, and have no analytics.</p>
|
<p>Impeccable is an open-source collection of agent skills (text files) that run locally in your AI coding tool. The skills themselves collect no data, make no network requests, and have no analytics.</p>
|
||||||
@@ -32,6 +32,17 @@
|
|||||||
<h2>Claude Code Plugin</h2>
|
<h2>Claude Code Plugin</h2>
|
||||||
<p>When installed as a Claude Code plugin, Impeccable runs entirely within your local Claude Code session. No data is sent to Impeccable's servers. Anthropic's own privacy policy governs the Claude Code application itself.</p>
|
<p>When installed as a Claude Code plugin, Impeccable runs entirely within your local Claude Code session. No data is sent to Impeccable's servers. Anthropic's own privacy policy governs the Claude Code application itself.</p>
|
||||||
|
|
||||||
|
<h2>Chrome Extension</h2>
|
||||||
|
<p>The Impeccable Chrome DevTools extension runs entirely in your browser. All anti-pattern detection happens locally on the page you are inspecting. No page content, URLs, or detection results are ever sent to any external server.</p>
|
||||||
|
<p>The extension stores your rule preferences (which detections are enabled or disabled) using Chrome's built-in sync storage (<code>chrome.storage.sync</code>), which syncs settings across your Chrome instances via your Google account. No other data is stored or transmitted.</p>
|
||||||
|
<p>The extension requests the following permissions:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>activeTab / scripting</strong> - to inject the detector script into the page you are inspecting</li>
|
||||||
|
<li><strong>storage</strong> - to save your rule preferences</li>
|
||||||
|
<li><strong>webNavigation</strong> - to re-scan automatically when you navigate to a new page</li>
|
||||||
|
<li><strong>Host permissions (all URLs)</strong> - so the detector can run on any website you choose to inspect</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2>GitHub</h2>
|
<h2>GitHub</h2>
|
||||||
<p>The source code is hosted on GitHub. Interactions with the repository (issues, pull requests, stars) are governed by <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">GitHub's privacy policy</a>.</p>
|
<p>The source code is hosted on GitHub. Interactions with the repository (issues, pull requests, stars) are governed by <a href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement">GitHub's privacy policy</a>.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the Chrome Web Store small promo tile (440x280) from an SVG template.
|
||||||
|
*
|
||||||
|
* Run: node scripts/generate-promo-tile.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 OUT = path.join(ROOT, 'extension/icons/promo-small.png');
|
||||||
|
|
||||||
|
// Brand colors
|
||||||
|
const BG = '#0e0d10';
|
||||||
|
const BG_TOP = '#161318';
|
||||||
|
const MAGENTA = '#cc1b89'; // approximates oklch(55% 0.25 350)
|
||||||
|
const TEXT = '#f5f3ef';
|
||||||
|
const TEXT_DIM = '#7a7680';
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg width="440" height="280" viewBox="0 0 440 280" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="${BG_TOP}"/>
|
||||||
|
<stop offset="1" stop-color="${BG}"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="slop" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#a855f7"/>
|
||||||
|
<stop offset="0.5" stop-color="#ec4899"/>
|
||||||
|
<stop offset="1" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="cardGlow" cx="50%" cy="50%" r="60%">
|
||||||
|
<stop offset="0" stop-color="#3a1a4a" stop-opacity="0.6"/>
|
||||||
|
<stop offset="1" stop-color="#1a0f24" stop-opacity="0.3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
|
||||||
|
<feOffset dx="0" dy="3" result="offsetblur"/>
|
||||||
|
<feComponentTransfer><feFuncA type="linear" slope="0.4"/></feComponentTransfer>
|
||||||
|
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="440" height="280" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<!-- Subtle grid texture -->
|
||||||
|
<g opacity="0.04" stroke="${TEXT}" stroke-width="0.5">
|
||||||
|
<line x1="0" y1="70" x2="440" y2="70"/>
|
||||||
|
<line x1="0" y1="140" x2="440" y2="140"/>
|
||||||
|
<line x1="0" y1="210" x2="440" y2="210"/>
|
||||||
|
<line x1="110" y1="0" x2="110" y2="280"/>
|
||||||
|
<line x1="220" y1="0" x2="220" y2="280"/>
|
||||||
|
<line x1="330" y1="0" x2="330" y2="280"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Brand wordmark (top-left) -->
|
||||||
|
<g transform="translate(28, 26)">
|
||||||
|
<rect width="24" height="24" rx="4.5" fill="#1a1a1a"/>
|
||||||
|
<!-- Slash matches the brand icon: (76,24)→(52,104) in 128 viewBox, scaled to 24 -->
|
||||||
|
<line x1="14.25" y1="4.5" x2="9.75" y2="19.5" stroke="${TEXT}" stroke-width="1.3" stroke-linecap="round"/>
|
||||||
|
<text x="34" y="17" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" font-size="16" font-weight="600" fill="${TEXT}" letter-spacing="-0.01em">Impeccable</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Small badge top-right -->
|
||||||
|
<g transform="translate(338, 28)">
|
||||||
|
<rect width="74" height="20" rx="10" fill="${TEXT}" fill-opacity="0.06"/>
|
||||||
|
<text x="37" y="14" text-anchor="middle" font-family="-apple-system, system-ui, sans-serif" font-size="10" font-weight="500" fill="${TEXT_DIM}" letter-spacing="0.04em">DEVTOOLS</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Demo card group (centered, slightly offset) -->
|
||||||
|
<g transform="translate(64, 92)">
|
||||||
|
<!-- Faux UI card -->
|
||||||
|
<g filter="url(#softShadow)">
|
||||||
|
<rect x="0" y="0" width="312" height="92" rx="14" fill="#1a1422"/>
|
||||||
|
<rect x="0" y="0" width="312" height="92" rx="14" fill="url(#cardGlow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Sparkles inside the card (more AI slop vibes) -->
|
||||||
|
<text x="32" y="56" font-family="-apple-system, system-ui, sans-serif" font-size="20" fill="#fbbf24">✨</text>
|
||||||
|
<text x="268" y="56" font-family="-apple-system, system-ui, sans-serif" font-size="20" fill="#fbbf24">✨</text>
|
||||||
|
|
||||||
|
<!-- Gradient text headline (the slop being detected) -->
|
||||||
|
<text x="156" y="52" text-anchor="middle"
|
||||||
|
font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif"
|
||||||
|
font-size="20" font-weight="700"
|
||||||
|
fill="url(#slop)">AI-Powered Magic</text>
|
||||||
|
|
||||||
|
<!-- Subline -->
|
||||||
|
<text x="156" y="72" text-anchor="middle"
|
||||||
|
font-family="-apple-system, system-ui, sans-serif"
|
||||||
|
font-size="10" fill="#9b94a8" letter-spacing="0.02em">Reimagining the future of everything</text>
|
||||||
|
|
||||||
|
<!-- Impeccable magenta outline (offset by 2px outside the card)
|
||||||
|
Path so top-left corner is square (where label meets it) -->
|
||||||
|
<path d="M -4 -4 L 312 -4 Q 316 -4 316 0 L 316 92 Q 316 96 312 96 L 0 96 Q -4 96 -4 92 L -4 -4 Z"
|
||||||
|
fill="none" stroke="${MAGENTA}" stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Label tab on top, flush with outline's outer edge.
|
||||||
|
Label extends to x=-5 (matching outline visible left edge) and y=-3 (covering the outline's top stroke). -->
|
||||||
|
<path d="M -5 -3 L -5 -22 Q -5 -26 -1 -26 L 113 -26 Q 117 -26 117 -22 L 117 -3 Z"
|
||||||
|
fill="${MAGENTA}"/>
|
||||||
|
<text x="2" y="-10"
|
||||||
|
font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif"
|
||||||
|
font-size="11" font-weight="600" fill="white" letter-spacing="0.01em">
|
||||||
|
✦ gradient text
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Tagline at bottom -->
|
||||||
|
<text x="28" y="244"
|
||||||
|
font-family="-apple-system, BlinkMacSystemFont, system-ui, sans-serif"
|
||||||
|
font-size="15" font-weight="600" fill="${TEXT}" letter-spacing="-0.01em">
|
||||||
|
Detect AI slop in any web page.
|
||||||
|
</text>
|
||||||
|
<text x="28" y="262"
|
||||||
|
font-family="-apple-system, system-ui, sans-serif"
|
||||||
|
font-size="11" fill="${TEXT_DIM}" letter-spacing="0.01em">
|
||||||
|
24 detections · Open DevTools and see what needs fixing.
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 440, height: 280, deviceScaleFactor: 1 });
|
||||||
|
await page.setContent(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><style>
|
||||||
|
* { margin: 0; padding: 0; }
|
||||||
|
body { width: 440px; height: 280px; overflow: hidden; }
|
||||||
|
</style></head>
|
||||||
|
<body>${svg}</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
await page.screenshot({ path: OUT, omitBackground: false });
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(`Generated ${path.relative(ROOT, OUT)}`);
|
||||||
@@ -52,6 +52,26 @@ const OVERUSED_FONTS = new Set([
|
|||||||
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
|
||||||
|
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
|
||||||
|
const GOOGLE_DOMAINS = [
|
||||||
|
'google.com', 'youtube.com', 'android.com', 'chromium.org',
|
||||||
|
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
|
||||||
|
];
|
||||||
|
const BRAND_FONT_DOMAINS = {
|
||||||
|
'roboto': GOOGLE_DOMAINS,
|
||||||
|
'google sans': GOOGLE_DOMAINS,
|
||||||
|
'product sans': GOOGLE_DOMAINS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBrandFontOnOwnDomain(font) {
|
||||||
|
if (typeof location === 'undefined') return false;
|
||||||
|
const allowed = BRAND_FONT_DOMAINS[font];
|
||||||
|
if (!allowed) return false;
|
||||||
|
const host = location.hostname.toLowerCase();
|
||||||
|
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||||
|
}
|
||||||
|
|
||||||
const GENERIC_FONTS = new Set([
|
const GENERIC_FONTS = new Set([
|
||||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||||
@@ -808,11 +828,12 @@ function checkElementQualityDOM(el) {
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
// --- Line length too long ---
|
// --- Line length too long ---
|
||||||
// Only flag if text is long enough to actually fill the line (>80 chars)
|
// Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80)
|
||||||
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
|
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||||
|
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||||
const charsPerLine = rect.width / (fontSize * 0.5);
|
const charsPerLine = rect.width / (fontSize * 0.5);
|
||||||
if (charsPerLine > 85) {
|
if (charsPerLine > lineMax + 5) {
|
||||||
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
|
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -864,8 +885,12 @@ function checkElementQualityDOM(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Tiny body text ---
|
// --- Tiny body text ---
|
||||||
|
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||||
if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
|
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||||
|
const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||||
|
const isUppercase = style.textTransform === 'uppercase';
|
||||||
|
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -990,6 +1015,7 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const font of overusedFound) {
|
for (const font of overusedFound) {
|
||||||
|
if (isBrandFontOnOwnDomain(font)) continue;
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
||||||
}
|
}
|
||||||
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
||||||
@@ -1236,7 +1262,11 @@ function checkPageLayout(doc, win) {
|
|||||||
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
||||||
|
|
||||||
if (IS_BROWSER) {
|
if (IS_BROWSER) {
|
||||||
const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true';
|
// Detect extension mode via the script tag's data attribute or the document element fallback.
|
||||||
|
// currentScript is reliable for synchronously-executing scripts (which our IIFE is).
|
||||||
|
const _myScript = document.currentScript;
|
||||||
|
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|
||||||
|
|| document.documentElement.dataset.impeccableExtension === 'true';
|
||||||
|
|
||||||
const BRAND_COLOR = 'oklch(55% 0.25 350)';
|
const BRAND_COLOR = 'oklch(55% 0.25 350)';
|
||||||
const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
|
const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
|
||||||
@@ -1266,18 +1296,103 @@ if (IS_BROWSER) {
|
|||||||
outline-color: ${BRAND_COLOR_HOVER};
|
outline-color: ${BRAND_COLOR_HOVER};
|
||||||
z-index: 100001 !important;
|
z-index: 100001 !important;
|
||||||
}
|
}
|
||||||
.impeccable-label {
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
.impeccable-overlay.impeccable-hover .impeccable-label {
|
.impeccable-overlay.impeccable-hover .impeccable-label {
|
||||||
background: ${BRAND_COLOR_HOVER};
|
background: ${BRAND_COLOR_HOVER};
|
||||||
}
|
}
|
||||||
|
.impeccable-overlay.impeccable-spotlight {
|
||||||
|
z-index: 100002 !important;
|
||||||
|
}
|
||||||
|
.impeccable-overlay.impeccable-spotlight-dimmed {
|
||||||
|
opacity: 0.15 !important;
|
||||||
|
animation: none !important;
|
||||||
|
filter: blur(3px);
|
||||||
|
}
|
||||||
|
.impeccable-spotlight-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
backdrop-filter: blur(3px) brightness(0.6);
|
||||||
|
-webkit-backdrop-filter: blur(3px) brightness(0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 99998;
|
||||||
|
opacity: 0;
|
||||||
|
outline: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.impeccable-spotlight-backdrop.impeccable-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
(document.head || document.documentElement).appendChild(styleEl);
|
(document.head || document.documentElement).appendChild(styleEl);
|
||||||
|
|
||||||
|
// Spotlight backdrop element (created lazily on first use)
|
||||||
|
let spotlightBackdrop = null;
|
||||||
|
let spotlightTarget = null;
|
||||||
|
let spotlightTimer = null;
|
||||||
|
|
||||||
|
function getSpotlightBackdrop() {
|
||||||
|
if (!spotlightBackdrop) {
|
||||||
|
spotlightBackdrop = document.createElement('div');
|
||||||
|
spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
|
||||||
|
document.body.appendChild(spotlightBackdrop);
|
||||||
|
}
|
||||||
|
return spotlightBackdrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpotlightClipPath() {
|
||||||
|
if (!spotlightBackdrop || !spotlightTarget) return;
|
||||||
|
const r = spotlightTarget.getBoundingClientRect();
|
||||||
|
// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
|
||||||
|
const inset = 4;
|
||||||
|
const radius = 6; // outline border-radius (4) + outline width (2)
|
||||||
|
const x1 = r.left - inset;
|
||||||
|
const y1 = r.top - inset;
|
||||||
|
const x2 = r.right + inset;
|
||||||
|
const y2 = r.bottom + inset;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
// Outer rect + rounded inner rect (evenodd creates a hole)
|
||||||
|
const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
|
||||||
|
spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSpotlight(target) {
|
||||||
|
if (!target || !target.getBoundingClientRect) return;
|
||||||
|
// Respect the spotlightBlur setting: if disabled, don't show the backdrop
|
||||||
|
if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
|
||||||
|
spotlightTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spotlightTarget = target;
|
||||||
|
const bd = getSpotlightBackdrop();
|
||||||
|
updateSpotlightClipPath();
|
||||||
|
bd.classList.add('impeccable-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSpotlight() {
|
||||||
|
spotlightTarget = null;
|
||||||
|
if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
|
||||||
|
if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInViewport(el) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposition spotlight on scroll/resize
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (spotlightTarget) updateSpotlightClipPath();
|
||||||
|
}, { passive: true });
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (spotlightTarget) updateSpotlightClipPath();
|
||||||
|
});
|
||||||
|
|
||||||
const overlays = [];
|
const overlays = [];
|
||||||
const TYPE_LABELS = {};
|
const TYPE_LABELS = {};
|
||||||
const RULE_CATEGORY = {};
|
const RULE_CATEGORY = {};
|
||||||
@@ -1315,6 +1430,8 @@ if (IS_BROWSER) {
|
|||||||
function repositionOverlays() {
|
function repositionOverlays() {
|
||||||
for (const o of overlays) {
|
for (const o of overlays) {
|
||||||
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
||||||
|
// Skip overlays whose target is currently hidden (display: none on the overlay)
|
||||||
|
if (o.style.display === 'none') continue;
|
||||||
positionOverlay(o);
|
positionOverlay(o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1325,6 +1442,13 @@ if (IS_BROWSER) {
|
|||||||
resizeRAF = requestAnimationFrame(repositionOverlays);
|
resizeRAF = requestAnimationFrame(repositionOverlays);
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
// Reposition on scroll too -- catches sticky/parallax shifts
|
||||||
|
window.addEventListener('scroll', onResize, { passive: true });
|
||||||
|
// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const bodyResizeObserver = new ResizeObserver(onResize);
|
||||||
|
bodyResizeObserver.observe(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
// Track target element visibility via IntersectionObserver.
|
// Track target element visibility via IntersectionObserver.
|
||||||
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
||||||
@@ -1340,7 +1464,13 @@ if (IS_BROWSER) {
|
|||||||
positionOverlay(overlay);
|
positionOverlay(overlay);
|
||||||
if (!overlay._revealed) {
|
if (!overlay._revealed) {
|
||||||
overlay._revealed = true;
|
overlay._revealed = true;
|
||||||
overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`;
|
if (firstScanDone) {
|
||||||
|
// Subsequent reveals (re-scans, scroll-into-view): instant, no animation
|
||||||
|
overlay.style.animation = 'none';
|
||||||
|
} else {
|
||||||
|
// Initial scan: staggered cascade reveal
|
||||||
|
overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
|
||||||
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
overlay.classList.add('impeccable-visible');
|
overlay.classList.add('impeccable-visible');
|
||||||
if (overlay._checkLabel) overlay._checkLabel();
|
if (overlay._checkLabel) overlay._checkLabel();
|
||||||
@@ -1606,6 +1736,13 @@ if (IS_BROWSER) {
|
|||||||
return parts.join(' > ');
|
return parts.join(' > ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isElementHidden(el) {
|
||||||
|
if (!el || el === document.body || el === document.documentElement) return false;
|
||||||
|
if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
|
||||||
|
// Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
|
||||||
|
return el.offsetWidth === 0 && el.offsetHeight === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeFindings(allFindings) {
|
function serializeFindings(allFindings) {
|
||||||
return allFindings.map(({ el, findings }) => ({
|
return allFindings.map(({ el, findings }) => ({
|
||||||
selector: generateSelector(el),
|
selector: generateSelector(el),
|
||||||
@@ -1613,6 +1750,7 @@ if (IS_BROWSER) {
|
|||||||
rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
|
rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
|
||||||
? el.getBoundingClientRect().toJSON() : null,
|
? el.getBoundingClientRect().toJSON() : null,
|
||||||
isPageLevel: el === document.body || el === document.documentElement,
|
isPageLevel: el === document.body || el === document.documentElement,
|
||||||
|
isHidden: isElementHidden(el),
|
||||||
findings: findings.map(f => {
|
findings: findings.map(f => {
|
||||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||||
return {
|
return {
|
||||||
@@ -1644,18 +1782,19 @@ if (IS_BROWSER) {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let firstScanDone = false;
|
||||||
const scan = function() {
|
const scan = function() {
|
||||||
for (const o of overlays) o.remove();
|
for (const o of overlays) o.remove();
|
||||||
overlays.length = 0;
|
overlays.length = 0;
|
||||||
visibilityObserver.disconnect();
|
visibilityObserver.disconnect();
|
||||||
|
overlayIndex = 0;
|
||||||
const allFindings = [];
|
const allFindings = [];
|
||||||
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
||||||
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
||||||
|
|
||||||
for (const el of document.querySelectorAll('*')) {
|
for (const el of document.querySelectorAll('*')) {
|
||||||
if (el.classList.contains('impeccable-overlay') ||
|
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
||||||
el.classList.contains('impeccable-label') ||
|
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||||
el.classList.contains('impeccable-tooltip')) continue;
|
|
||||||
// Skip browser extension elements (Claude, etc.)
|
// Skip browser extension elements (Claude, etc.)
|
||||||
const elId = el.id || '';
|
const elId = el.id || '';
|
||||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
||||||
@@ -1733,6 +1872,9 @@ if (IS_BROWSER) {
|
|||||||
}, '*');
|
}, '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After this scan completes, all subsequent reveals are instant (no stagger, no animation)
|
||||||
|
setTimeout(() => { firstScanDone = true; }, 1000);
|
||||||
|
|
||||||
return allFindings;
|
return allFindings;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1754,8 +1896,43 @@ if (IS_BROWSER) {
|
|||||||
overlays.length = 0;
|
overlays.length = 0;
|
||||||
visibilityObserver.disconnect();
|
visibilityObserver.disconnect();
|
||||||
styleEl.remove();
|
styleEl.remove();
|
||||||
|
if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
|
||||||
document.body.classList.remove('impeccable-hidden');
|
document.body.classList.remove('impeccable-hidden');
|
||||||
}
|
}
|
||||||
|
if (e.data.action === 'highlight') {
|
||||||
|
if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
|
||||||
|
try {
|
||||||
|
const target = e.data.selector ? document.querySelector(e.data.selector) : null;
|
||||||
|
if (target) {
|
||||||
|
// Scroll first so positionOverlay reads the post-scroll rect
|
||||||
|
if (!isInViewport(target) && target.scrollIntoView) {
|
||||||
|
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||||
|
}
|
||||||
|
for (const o of overlays) {
|
||||||
|
if (o.classList.contains('impeccable-banner')) continue;
|
||||||
|
const isMatch = o._targetEl === target;
|
||||||
|
o.classList.toggle('impeccable-spotlight', isMatch);
|
||||||
|
o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
|
||||||
|
if (isMatch) {
|
||||||
|
// Force the matching overlay visible immediately, don't wait for IntersectionObserver
|
||||||
|
o.style.display = '';
|
||||||
|
o.style.animation = 'none';
|
||||||
|
o.classList.add('impeccable-visible');
|
||||||
|
o._revealed = true;
|
||||||
|
positionOverlay(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSpotlight(target);
|
||||||
|
}
|
||||||
|
} catch { /* invalid selector */ }
|
||||||
|
}
|
||||||
|
if (e.data.action === 'unhighlight') {
|
||||||
|
hideSpotlight();
|
||||||
|
for (const o of overlays) {
|
||||||
|
o.classList.remove('impeccable-spotlight');
|
||||||
|
o.classList.remove('impeccable-spotlight-dimmed');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.postMessage({ source: 'impeccable-ready' }, '*');
|
window.postMessage({ source: 'impeccable-ready' }, '*');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+190
-13
@@ -47,6 +47,26 @@ const OVERUSED_FONTS = new Set([
|
|||||||
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
|
||||||
|
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
|
||||||
|
const GOOGLE_DOMAINS = [
|
||||||
|
'google.com', 'youtube.com', 'android.com', 'chromium.org',
|
||||||
|
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
|
||||||
|
];
|
||||||
|
const BRAND_FONT_DOMAINS = {
|
||||||
|
'roboto': GOOGLE_DOMAINS,
|
||||||
|
'google sans': GOOGLE_DOMAINS,
|
||||||
|
'product sans': GOOGLE_DOMAINS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBrandFontOnOwnDomain(font) {
|
||||||
|
if (typeof location === 'undefined') return false;
|
||||||
|
const allowed = BRAND_FONT_DOMAINS[font];
|
||||||
|
if (!allowed) return false;
|
||||||
|
const host = location.hostname.toLowerCase();
|
||||||
|
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
||||||
|
}
|
||||||
|
|
||||||
const GENERIC_FONTS = new Set([
|
const GENERIC_FONTS = new Set([
|
||||||
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
||||||
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
||||||
@@ -803,11 +823,12 @@ function checkElementQualityDOM(el) {
|
|||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
|
||||||
// --- Line length too long ---
|
// --- Line length too long ---
|
||||||
// Only flag if text is long enough to actually fill the line (>80 chars)
|
// Threshold is configurable via window.__IMPECCABLE_CONFIG__.lineLengthMax (default 80)
|
||||||
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
|
const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
|
||||||
|
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
||||||
const charsPerLine = rect.width / (fontSize * 0.5);
|
const charsPerLine = rect.width / (fontSize * 0.5);
|
||||||
if (charsPerLine > 85) {
|
if (charsPerLine > lineMax + 5) {
|
||||||
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
|
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -859,8 +880,12 @@ function checkElementQualityDOM(el) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Tiny body text ---
|
// --- Tiny body text ---
|
||||||
|
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||||
if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
|
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||||
|
const inUIContext = el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||||
|
const isUppercase = style.textTransform === 'uppercase';
|
||||||
|
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -985,6 +1010,7 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const font of overusedFound) {
|
for (const font of overusedFound) {
|
||||||
|
if (isBrandFontOnOwnDomain(font)) continue;
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
||||||
}
|
}
|
||||||
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
||||||
@@ -1231,7 +1257,11 @@ function checkPageLayout(doc, win) {
|
|||||||
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
||||||
|
|
||||||
if (IS_BROWSER) {
|
if (IS_BROWSER) {
|
||||||
const EXTENSION_MODE = document.documentElement.dataset.impeccableExtension === 'true';
|
// Detect extension mode via the script tag's data attribute or the document element fallback.
|
||||||
|
// currentScript is reliable for synchronously-executing scripts (which our IIFE is).
|
||||||
|
const _myScript = document.currentScript;
|
||||||
|
const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
|
||||||
|
|| document.documentElement.dataset.impeccableExtension === 'true';
|
||||||
|
|
||||||
const BRAND_COLOR = 'oklch(55% 0.25 350)';
|
const BRAND_COLOR = 'oklch(55% 0.25 350)';
|
||||||
const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
|
const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)';
|
||||||
@@ -1261,18 +1291,103 @@ if (IS_BROWSER) {
|
|||||||
outline-color: ${BRAND_COLOR_HOVER};
|
outline-color: ${BRAND_COLOR_HOVER};
|
||||||
z-index: 100001 !important;
|
z-index: 100001 !important;
|
||||||
}
|
}
|
||||||
.impeccable-label {
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
.impeccable-overlay.impeccable-hover .impeccable-label {
|
.impeccable-overlay.impeccable-hover .impeccable-label {
|
||||||
background: ${BRAND_COLOR_HOVER};
|
background: ${BRAND_COLOR_HOVER};
|
||||||
}
|
}
|
||||||
|
.impeccable-overlay.impeccable-spotlight {
|
||||||
|
z-index: 100002 !important;
|
||||||
|
}
|
||||||
|
.impeccable-overlay.impeccable-spotlight-dimmed {
|
||||||
|
opacity: 0.15 !important;
|
||||||
|
animation: none !important;
|
||||||
|
filter: blur(3px);
|
||||||
|
}
|
||||||
|
.impeccable-spotlight-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
backdrop-filter: blur(3px) brightness(0.6);
|
||||||
|
-webkit-backdrop-filter: blur(3px) brightness(0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 99998;
|
||||||
|
opacity: 0;
|
||||||
|
outline: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.impeccable-spotlight-backdrop.impeccable-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
(document.head || document.documentElement).appendChild(styleEl);
|
(document.head || document.documentElement).appendChild(styleEl);
|
||||||
|
|
||||||
|
// Spotlight backdrop element (created lazily on first use)
|
||||||
|
let spotlightBackdrop = null;
|
||||||
|
let spotlightTarget = null;
|
||||||
|
let spotlightTimer = null;
|
||||||
|
|
||||||
|
function getSpotlightBackdrop() {
|
||||||
|
if (!spotlightBackdrop) {
|
||||||
|
spotlightBackdrop = document.createElement('div');
|
||||||
|
spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
|
||||||
|
document.body.appendChild(spotlightBackdrop);
|
||||||
|
}
|
||||||
|
return spotlightBackdrop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpotlightClipPath() {
|
||||||
|
if (!spotlightBackdrop || !spotlightTarget) return;
|
||||||
|
const r = spotlightTarget.getBoundingClientRect();
|
||||||
|
// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
|
||||||
|
const inset = 4;
|
||||||
|
const radius = 6; // outline border-radius (4) + outline width (2)
|
||||||
|
const x1 = r.left - inset;
|
||||||
|
const y1 = r.top - inset;
|
||||||
|
const x2 = r.right + inset;
|
||||||
|
const y2 = r.bottom + inset;
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
// Outer rect + rounded inner rect (evenodd creates a hole)
|
||||||
|
const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
|
||||||
|
spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSpotlight(target) {
|
||||||
|
if (!target || !target.getBoundingClientRect) return;
|
||||||
|
// Respect the spotlightBlur setting: if disabled, don't show the backdrop
|
||||||
|
if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
|
||||||
|
spotlightTarget = target;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spotlightTarget = target;
|
||||||
|
const bd = getSpotlightBackdrop();
|
||||||
|
updateSpotlightClipPath();
|
||||||
|
bd.classList.add('impeccable-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSpotlight() {
|
||||||
|
spotlightTarget = null;
|
||||||
|
if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
|
||||||
|
if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInViewport(el) {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposition spotlight on scroll/resize
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (spotlightTarget) updateSpotlightClipPath();
|
||||||
|
}, { passive: true });
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (spotlightTarget) updateSpotlightClipPath();
|
||||||
|
});
|
||||||
|
|
||||||
const overlays = [];
|
const overlays = [];
|
||||||
const TYPE_LABELS = {};
|
const TYPE_LABELS = {};
|
||||||
const RULE_CATEGORY = {};
|
const RULE_CATEGORY = {};
|
||||||
@@ -1310,6 +1425,8 @@ if (IS_BROWSER) {
|
|||||||
function repositionOverlays() {
|
function repositionOverlays() {
|
||||||
for (const o of overlays) {
|
for (const o of overlays) {
|
||||||
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
||||||
|
// Skip overlays whose target is currently hidden (display: none on the overlay)
|
||||||
|
if (o.style.display === 'none') continue;
|
||||||
positionOverlay(o);
|
positionOverlay(o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1320,6 +1437,13 @@ if (IS_BROWSER) {
|
|||||||
resizeRAF = requestAnimationFrame(repositionOverlays);
|
resizeRAF = requestAnimationFrame(repositionOverlays);
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', onResize);
|
window.addEventListener('resize', onResize);
|
||||||
|
// Reposition on scroll too -- catches sticky/parallax shifts
|
||||||
|
window.addEventListener('scroll', onResize, { passive: true });
|
||||||
|
// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const bodyResizeObserver = new ResizeObserver(onResize);
|
||||||
|
bodyResizeObserver.observe(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
// Track target element visibility via IntersectionObserver.
|
// Track target element visibility via IntersectionObserver.
|
||||||
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
||||||
@@ -1335,7 +1459,13 @@ if (IS_BROWSER) {
|
|||||||
positionOverlay(overlay);
|
positionOverlay(overlay);
|
||||||
if (!overlay._revealed) {
|
if (!overlay._revealed) {
|
||||||
overlay._revealed = true;
|
overlay._revealed = true;
|
||||||
overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`;
|
if (firstScanDone) {
|
||||||
|
// Subsequent reveals (re-scans, scroll-into-view): instant, no animation
|
||||||
|
overlay.style.animation = 'none';
|
||||||
|
} else {
|
||||||
|
// Initial scan: staggered cascade reveal
|
||||||
|
overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
|
||||||
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
overlay.classList.add('impeccable-visible');
|
overlay.classList.add('impeccable-visible');
|
||||||
if (overlay._checkLabel) overlay._checkLabel();
|
if (overlay._checkLabel) overlay._checkLabel();
|
||||||
@@ -1601,6 +1731,13 @@ if (IS_BROWSER) {
|
|||||||
return parts.join(' > ');
|
return parts.join(' > ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isElementHidden(el) {
|
||||||
|
if (!el || el === document.body || el === document.documentElement) return false;
|
||||||
|
if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
|
||||||
|
// Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
|
||||||
|
return el.offsetWidth === 0 && el.offsetHeight === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeFindings(allFindings) {
|
function serializeFindings(allFindings) {
|
||||||
return allFindings.map(({ el, findings }) => ({
|
return allFindings.map(({ el, findings }) => ({
|
||||||
selector: generateSelector(el),
|
selector: generateSelector(el),
|
||||||
@@ -1608,6 +1745,7 @@ if (IS_BROWSER) {
|
|||||||
rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
|
rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
|
||||||
? el.getBoundingClientRect().toJSON() : null,
|
? el.getBoundingClientRect().toJSON() : null,
|
||||||
isPageLevel: el === document.body || el === document.documentElement,
|
isPageLevel: el === document.body || el === document.documentElement,
|
||||||
|
isHidden: isElementHidden(el),
|
||||||
findings: findings.map(f => {
|
findings: findings.map(f => {
|
||||||
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
||||||
return {
|
return {
|
||||||
@@ -1639,18 +1777,19 @@ if (IS_BROWSER) {
|
|||||||
console.groupEnd();
|
console.groupEnd();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let firstScanDone = false;
|
||||||
const scan = function() {
|
const scan = function() {
|
||||||
for (const o of overlays) o.remove();
|
for (const o of overlays) o.remove();
|
||||||
overlays.length = 0;
|
overlays.length = 0;
|
||||||
visibilityObserver.disconnect();
|
visibilityObserver.disconnect();
|
||||||
|
overlayIndex = 0;
|
||||||
const allFindings = [];
|
const allFindings = [];
|
||||||
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
||||||
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
||||||
|
|
||||||
for (const el of document.querySelectorAll('*')) {
|
for (const el of document.querySelectorAll('*')) {
|
||||||
if (el.classList.contains('impeccable-overlay') ||
|
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
||||||
el.classList.contains('impeccable-label') ||
|
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||||
el.classList.contains('impeccable-tooltip')) continue;
|
|
||||||
// Skip browser extension elements (Claude, etc.)
|
// Skip browser extension elements (Claude, etc.)
|
||||||
const elId = el.id || '';
|
const elId = el.id || '';
|
||||||
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
||||||
@@ -1728,6 +1867,9 @@ if (IS_BROWSER) {
|
|||||||
}, '*');
|
}, '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After this scan completes, all subsequent reveals are instant (no stagger, no animation)
|
||||||
|
setTimeout(() => { firstScanDone = true; }, 1000);
|
||||||
|
|
||||||
return allFindings;
|
return allFindings;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1749,8 +1891,43 @@ if (IS_BROWSER) {
|
|||||||
overlays.length = 0;
|
overlays.length = 0;
|
||||||
visibilityObserver.disconnect();
|
visibilityObserver.disconnect();
|
||||||
styleEl.remove();
|
styleEl.remove();
|
||||||
|
if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
|
||||||
document.body.classList.remove('impeccable-hidden');
|
document.body.classList.remove('impeccable-hidden');
|
||||||
}
|
}
|
||||||
|
if (e.data.action === 'highlight') {
|
||||||
|
if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; }
|
||||||
|
try {
|
||||||
|
const target = e.data.selector ? document.querySelector(e.data.selector) : null;
|
||||||
|
if (target) {
|
||||||
|
// Scroll first so positionOverlay reads the post-scroll rect
|
||||||
|
if (!isInViewport(target) && target.scrollIntoView) {
|
||||||
|
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||||
|
}
|
||||||
|
for (const o of overlays) {
|
||||||
|
if (o.classList.contains('impeccable-banner')) continue;
|
||||||
|
const isMatch = o._targetEl === target;
|
||||||
|
o.classList.toggle('impeccable-spotlight', isMatch);
|
||||||
|
o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
|
||||||
|
if (isMatch) {
|
||||||
|
// Force the matching overlay visible immediately, don't wait for IntersectionObserver
|
||||||
|
o.style.display = '';
|
||||||
|
o.style.animation = 'none';
|
||||||
|
o.classList.add('impeccable-visible');
|
||||||
|
o._revealed = true;
|
||||||
|
positionOverlay(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showSpotlight(target);
|
||||||
|
}
|
||||||
|
} catch { /* invalid selector */ }
|
||||||
|
}
|
||||||
|
if (e.data.action === 'unhighlight') {
|
||||||
|
hideSpotlight();
|
||||||
|
for (const o of overlays) {
|
||||||
|
o.classList.remove('impeccable-spotlight');
|
||||||
|
o.classList.remove('impeccable-spotlight-dimmed');
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
window.postMessage({ source: 'impeccable-ready' }, '*');
|
window.postMessage({ source: 'impeccable-ready' }, '*');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user