mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 13:46:32 +03:00
* Fix detector URL and advisory handling Recover joined URL arguments without splitting local paths, derive advisory behavior from registry severity across consumers, inspect readable linked CSS in URL scans, and report only the dominant primary font. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Filter linked CSS to rendered selectors Flatten linked stylesheet grouping rules and collect only selector rules that target the live DOM, preventing unused grouped and selector-less patterns from leaking into URL findings. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Fix detector review edge cases AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Preserve unresolved linked CSS selectors AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Fix linked CSS selector filtering Resolve pseudo-element selectors to live hosts, reject unresolvable linked CSS findings, and make the regression assertions independent. Also ignore comment delimiters when recovering CSS rule selectors. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Skip unresolved container query CSS Exclude linked container-query groups when their current applicability cannot be resolved, with a browser regression proving inactive styles do not leak. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect active container query CSS Use a temporary custom-property probe so the browser decides whether a nested style rule actually applies in the current container layout. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Filter inactive linked CSS states Keep valid empty pseudo-class matches authoritative and omit selector-less linked at-rules that cannot be tied to rendered nodes. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Parse pseudo-elements without rewriting literals Preserve quoted attribute values and escaped identifiers while resolving real pseudo-elements to live hosts. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction. * Restore live linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Handle grouped linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Respect keyframe definition order AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Resolve effective linked keyframes AI assistance disclosure: Codex helped implement and verify this fix under maintainer direction. * Fix keyframe easing detection Serialize effective per-keyframe easing back into the linked stylesheet corpus so overshoot motion is detected. Add a browser regression with a neutral animation name.\n\nAI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
2581 lines
102 KiB
JavaScript
2581 lines
102 KiB
JavaScript
const IS_BROWSER = typeof window !== 'undefined';
|
|
|
|
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
|
|
|
if (IS_BROWSER) {
|
|
// 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';
|
|
|
|
// Kinpaku gold — pinned to the site's brand token (see
|
|
// site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with
|
|
// the picker's C.brand in skill/scripts/live-browser.js and the kit's
|
|
// picker section in site/styles/kinpaku-kit.css.
|
|
//
|
|
// One color across both light and dark host pages. The outline is a
|
|
// 2px gesture pointing at an element + a labeled tag — it's a marker,
|
|
// not body text, so it doesn't need WCAG AA against the page. The
|
|
// label text inside the gold tag is dark (LABEL_INK) which has ~16:1
|
|
// against the leaf gold, so reading the rule name is solid in both
|
|
// modes. Hover deepens the gold (preserves chroma — never drops it,
|
|
// dropping chroma washes the gold into a sand/olive tone).
|
|
const BRAND_COLOR = 'oklch(84% 0.19 80.46)';
|
|
const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';
|
|
const LABEL_INK = 'oklch(4% 0.004 95)';
|
|
const LABEL_BG = BRAND_COLOR;
|
|
const OUTLINE_COLOR = BRAND_COLOR;
|
|
|
|
// Inject hover styles via CSS (more reliable than JS event listeners)
|
|
const styleEl = document.createElement('style');
|
|
styleEl.textContent = `
|
|
@keyframes impeccable-reveal {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
.impeccable-overlay:not(.impeccable-banner) {
|
|
pointer-events: none;
|
|
outline: 2px solid ${OUTLINE_COLOR};
|
|
border-radius: 4px;
|
|
transition: outline-color 0.15s ease;
|
|
animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
animation-play-state: paused;
|
|
border-top-left-radius: 0;
|
|
}
|
|
.impeccable-overlay.impeccable-visible {
|
|
animation-play-state: running;
|
|
}
|
|
.impeccable-overlay.impeccable-hover {
|
|
outline-color: ${BRAND_COLOR_HOVER};
|
|
z-index: 100001 !important;
|
|
}
|
|
.impeccable-overlay.impeccable-hover .impeccable-label {
|
|
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;
|
|
}
|
|
`;
|
|
(document.head || document.documentElement).appendChild(styleEl);
|
|
|
|
// Spotlight backdrop element (created lazily on first use)
|
|
let spotlightBackdrop = null;
|
|
let spotlightTarget = 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 (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 TYPE_LABELS = {};
|
|
const RULE_CATEGORY = {};
|
|
for (const ap of ANTIPATTERNS) {
|
|
TYPE_LABELS[ap.id] = ap.name.toLowerCase();
|
|
RULE_CATEGORY[ap.id] = ap.category || 'quality';
|
|
}
|
|
|
|
function isInFixedContext(el) {
|
|
let p = el;
|
|
while (p && p !== document.body) {
|
|
if (getComputedStyle(p).position === 'fixed') return true;
|
|
p = p.parentElement;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function positionOverlay(overlay) {
|
|
const el = overlay._targetEl;
|
|
if (!el) return;
|
|
const rect = el.getBoundingClientRect();
|
|
if (overlay._isFixed) {
|
|
// Viewport-relative coords for fixed targets
|
|
overlay.style.top = `${rect.top - 2}px`;
|
|
overlay.style.left = `${rect.left - 2}px`;
|
|
} else {
|
|
// Document-relative coords for normal targets
|
|
overlay.style.top = `${rect.top + scrollY - 2}px`;
|
|
overlay.style.left = `${rect.left + scrollX - 2}px`;
|
|
}
|
|
overlay.style.width = `${rect.width + 4}px`;
|
|
overlay.style.height = `${rect.height + 4}px`;
|
|
}
|
|
|
|
function repositionOverlays() {
|
|
for (const o of overlays) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
let resizeRAF;
|
|
const onResize = () => {
|
|
cancelAnimationFrame(resizeRAF);
|
|
resizeRAF = requestAnimationFrame(repositionOverlays);
|
|
};
|
|
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.
|
|
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
|
// while display:none / closed <details> / hidden modals etc. do not.
|
|
// This is event-driven -- no polling needed.
|
|
let overlayIndex = 0;
|
|
const visibilityObserver = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const overlay = entry.target._impeccableOverlay;
|
|
if (!overlay) continue;
|
|
if (entry.isIntersecting) {
|
|
overlay.style.display = '';
|
|
positionOverlay(overlay);
|
|
if (!overlay._revealed) {
|
|
overlay._revealed = true;
|
|
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(() => {
|
|
overlay.classList.add('impeccable-visible');
|
|
if (overlay._checkLabel) overlay._checkLabel();
|
|
});
|
|
}
|
|
} else {
|
|
overlay.style.display = 'none';
|
|
}
|
|
}
|
|
}, { rootMargin: '99999px' });
|
|
|
|
function detachOverlay(overlay) {
|
|
if (!overlay) return;
|
|
if (typeof overlay._cleanup === 'function') {
|
|
try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }
|
|
}
|
|
if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {
|
|
visibilityObserver.unobserve(overlay._targetEl);
|
|
delete overlay._targetEl._impeccableOverlay;
|
|
}
|
|
const idx = overlays.indexOf(overlay);
|
|
if (idx >= 0) overlays.splice(idx, 1);
|
|
overlay.remove();
|
|
}
|
|
|
|
// Reposition overlays after CSS transitions end (e.g. reveal animations).
|
|
// Listens at document level so it catches transitions on ancestor elements
|
|
// (the transform may be on a parent, not the flagged element itself).
|
|
document.addEventListener('transitionend', (e) => {
|
|
if (e.propertyName !== 'transform') return;
|
|
for (const o of overlays) {
|
|
if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
|
|
if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
|
|
positionOverlay(o);
|
|
}
|
|
}
|
|
});
|
|
|
|
const highlight = function(el, findings) {
|
|
if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);
|
|
const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
|
|
|
|
const fixed = isInFixedContext(el);
|
|
const rect = el.getBoundingClientRect();
|
|
const outline = document.createElement('div');
|
|
outline.className = 'impeccable-overlay';
|
|
outline._targetEl = el;
|
|
outline._isFixed = fixed;
|
|
Object.assign(outline.style, {
|
|
position: fixed ? 'fixed' : 'absolute',
|
|
top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
|
|
left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
|
|
width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
|
|
zIndex: '99999', boxSizing: 'border-box',
|
|
});
|
|
|
|
// Build per-finding label entries: ✦ prefix for slop
|
|
const entries = findings.map(f => {
|
|
const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
|
|
const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
|
|
return { name: prefix + name, detail: f.detail || f.snippet };
|
|
});
|
|
const allText = entries.map(e => e.name).join(', ');
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'impeccable-label';
|
|
Object.assign(label.style, {
|
|
position: 'absolute', bottom: '100%', left: '-2px',
|
|
display: 'flex', alignItems: 'center',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
|
|
color: LABEL_INK, lineHeight: '14px',
|
|
background: LABEL_BG,
|
|
fontFamily: 'system-ui, sans-serif',
|
|
borderRadius: '4px 4px 0 0',
|
|
});
|
|
|
|
const textSpan = document.createElement('span');
|
|
textSpan.style.padding = '3px 8px';
|
|
textSpan.textContent = allText;
|
|
label.appendChild(textSpan);
|
|
|
|
// State for cycling mode
|
|
let cycleMode = false;
|
|
let cycleIndex = 0;
|
|
let isHovered = false;
|
|
let prevBtn, nextBtn;
|
|
|
|
function updateCycleText() {
|
|
const e = entries[cycleIndex];
|
|
textSpan.textContent = isHovered ? e.detail : e.name;
|
|
}
|
|
|
|
function enableCycleMode() {
|
|
if (cycleMode || entries.length < 2) return;
|
|
cycleMode = true;
|
|
|
|
const btnStyle = {
|
|
background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
|
|
fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
|
|
fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
|
|
pointerEvents: 'auto',
|
|
};
|
|
|
|
const navGroup = document.createElement('span');
|
|
Object.assign(navGroup.style, {
|
|
display: 'inline-flex', alignItems: 'center', flexShrink: '0',
|
|
});
|
|
|
|
prevBtn = document.createElement('button');
|
|
prevBtn.textContent = '\u2039';
|
|
Object.assign(prevBtn.style, btnStyle);
|
|
prevBtn.style.paddingLeft = '6px';
|
|
prevBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
|
|
updateCycleText();
|
|
});
|
|
|
|
nextBtn = document.createElement('button');
|
|
nextBtn.textContent = '\u203A';
|
|
Object.assign(nextBtn.style, btnStyle);
|
|
nextBtn.style.paddingRight = '2px';
|
|
nextBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
cycleIndex = (cycleIndex + 1) % entries.length;
|
|
updateCycleText();
|
|
});
|
|
|
|
navGroup.appendChild(prevBtn);
|
|
navGroup.appendChild(nextBtn);
|
|
label.insertBefore(navGroup, textSpan);
|
|
textSpan.style.padding = '3px 8px 3px 4px';
|
|
updateCycleText();
|
|
}
|
|
|
|
outline.appendChild(label);
|
|
|
|
// Start hidden; the IntersectionObserver will show it once the target is rendered
|
|
outline.style.display = 'none';
|
|
outline._staggerIndex = overlayIndex++;
|
|
el._impeccableOverlay = outline;
|
|
visibilityObserver.observe(el);
|
|
|
|
// After first paint, check label width vs outline
|
|
outline._checkLabel = () => {
|
|
if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
|
|
enableCycleMode();
|
|
}
|
|
};
|
|
|
|
// Hover: show detail text, darken
|
|
const onMouseEnter = () => {
|
|
isHovered = true;
|
|
outline.classList.add('impeccable-hover');
|
|
outline.style.outlineColor = BRAND_COLOR_HOVER;
|
|
label.style.background = BRAND_COLOR_HOVER;
|
|
if (cycleMode) {
|
|
updateCycleText();
|
|
} else {
|
|
textSpan.textContent = entries.map(e => e.detail).join(' | ');
|
|
}
|
|
};
|
|
const onMouseLeave = () => {
|
|
isHovered = false;
|
|
outline.classList.remove('impeccable-hover');
|
|
outline.style.outlineColor = '';
|
|
label.style.background = LABEL_BG;
|
|
if (cycleMode) {
|
|
updateCycleText();
|
|
} else {
|
|
textSpan.textContent = allText;
|
|
}
|
|
};
|
|
el.addEventListener('mouseenter', onMouseEnter);
|
|
el.addEventListener('mouseleave', onMouseLeave);
|
|
outline._cleanup = () => {
|
|
el.removeEventListener('mouseenter', onMouseEnter);
|
|
el.removeEventListener('mouseleave', onMouseLeave);
|
|
};
|
|
|
|
document.body.appendChild(outline);
|
|
overlays.push(outline);
|
|
};
|
|
|
|
const showPageBanner = function(findings) {
|
|
if (!findings.length) return;
|
|
const banner = document.createElement('div');
|
|
banner.className = 'impeccable-overlay impeccable-banner';
|
|
Object.assign(banner.style, {
|
|
position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
|
|
background: LABEL_BG, color: LABEL_INK,
|
|
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
|
|
display: 'flex', alignItems: 'center', pointerEvents: 'auto',
|
|
height: '36px', overflow: 'hidden', maxWidth: '100vw',
|
|
transform: 'translateY(-100%)',
|
|
transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
});
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
banner.style.transform = 'translateY(0)';
|
|
}));
|
|
|
|
// Scrollable findings area
|
|
const scrollArea = document.createElement('div');
|
|
Object.assign(scrollArea.style, {
|
|
flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
|
|
display: 'flex', gap: '8px', alignItems: 'center',
|
|
padding: '0 12px', scrollSnapType: 'x mandatory',
|
|
scrollbarWidth: 'none',
|
|
});
|
|
for (const f of findings) {
|
|
const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
|
|
const tag = document.createElement('span');
|
|
tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
|
|
Object.assign(tag.style, {
|
|
background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
|
|
borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
|
|
whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
|
|
});
|
|
scrollArea.appendChild(tag);
|
|
}
|
|
banner.appendChild(scrollArea);
|
|
|
|
// Controls area (only in standalone mode, not extension)
|
|
if (!EXTENSION_MODE) {
|
|
const controls = document.createElement('div');
|
|
Object.assign(controls.style, {
|
|
display: 'flex', alignItems: 'center', gap: '2px',
|
|
padding: '0 8px', flexShrink: '0',
|
|
});
|
|
|
|
// Toggle visibility button
|
|
const toggle = document.createElement('button');
|
|
toggle.textContent = '\u25C9'; // circle with dot (visible state)
|
|
toggle.title = 'Toggle overlay visibility';
|
|
Object.assign(toggle.style, {
|
|
background: 'none', border: 'none',
|
|
color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
|
|
opacity: '0.85', transition: 'opacity 0.15s',
|
|
});
|
|
let overlaysVisible = true;
|
|
toggle.addEventListener('click', () => {
|
|
overlaysVisible = !overlaysVisible;
|
|
document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
|
|
toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
|
|
toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
|
|
});
|
|
controls.appendChild(toggle);
|
|
|
|
// Close button
|
|
const close = document.createElement('button');
|
|
close.textContent = '\u00d7';
|
|
close.title = 'Dismiss banner';
|
|
Object.assign(close.style, {
|
|
background: 'none', border: 'none',
|
|
color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
|
|
});
|
|
close.addEventListener('click', () => banner.remove());
|
|
controls.appendChild(close);
|
|
|
|
banner.appendChild(controls);
|
|
}
|
|
document.body.appendChild(banner);
|
|
overlays.push(banner);
|
|
};
|
|
|
|
// Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
|
|
// These change between builds and produce brittle, ugly selectors.
|
|
function isLikelyHashedClass(c) {
|
|
if (!c) return true;
|
|
if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
|
|
if (/^_[\w-]{5,}$/.test(c)) return true;
|
|
if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
|
|
return false;
|
|
}
|
|
|
|
function buildSelectorSegment(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
let sel = tag;
|
|
|
|
if (el.classList && el.classList.length > 0) {
|
|
const classes = [...el.classList]
|
|
.filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
|
|
.slice(0, 2);
|
|
if (classes.length > 0) {
|
|
sel += '.' + classes.map(c => CSS.escape(c)).join('.');
|
|
}
|
|
}
|
|
|
|
// Disambiguate among siblings only if the parent has multiple matches
|
|
const parent = el.parentElement;
|
|
if (parent) {
|
|
try {
|
|
const matching = parent.querySelectorAll(':scope > ' + sel);
|
|
if (matching.length > 1) {
|
|
const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
|
|
const idx = sameType.indexOf(el) + 1;
|
|
sel += `:nth-of-type(${idx})`;
|
|
}
|
|
} catch {
|
|
const idx = [...parent.children].indexOf(el) + 1;
|
|
sel = `${tag}:nth-child(${idx})`;
|
|
}
|
|
}
|
|
return sel;
|
|
}
|
|
|
|
function generateSelector(el) {
|
|
if (el === document.body) return 'body';
|
|
if (el === document.documentElement) return 'html';
|
|
// Read via getAttribute when `el.id` is not a string — a <form> with a
|
|
// named control (e.g. <input name="id">) shadows the builtin getter and
|
|
// returns the element, producing a garbage `#[object …]` selector (#407).
|
|
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
|
if (elId) return '#' + CSS.escape(elId);
|
|
|
|
const parts = [];
|
|
let current = el;
|
|
let depth = 0;
|
|
const MAX_DEPTH = 10;
|
|
|
|
while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
|
|
parts.unshift(buildSelectorSegment(current));
|
|
|
|
// Anchor on an ancestor's ID and stop walking up
|
|
if (current.id) {
|
|
parts[0] = '#' + CSS.escape(current.id);
|
|
break;
|
|
}
|
|
|
|
// Stop as soon as the partial selector uniquely identifies the target
|
|
const trySelector = parts.join(' > ');
|
|
try {
|
|
const matches = document.querySelectorAll(trySelector);
|
|
if (matches.length === 1 && matches[0] === el) {
|
|
return trySelector;
|
|
}
|
|
} catch { /* invalid selector — keep walking */ }
|
|
|
|
current = current.parentElement;
|
|
depth++;
|
|
}
|
|
|
|
return parts.join(' > ');
|
|
}
|
|
|
|
function getDirectText(el) {
|
|
return [...el.childNodes]
|
|
.filter(n => n.nodeType === 3)
|
|
.map(n => n.textContent || '')
|
|
.join('');
|
|
}
|
|
|
|
function getDirectTextRect(el) {
|
|
const rects = [];
|
|
for (const node of el.childNodes) {
|
|
if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;
|
|
const range = document.createRange();
|
|
range.selectNodeContents(node);
|
|
for (const rect of range.getClientRects()) {
|
|
if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
|
|
}
|
|
range.detach?.();
|
|
}
|
|
if (rects.length === 0) return null;
|
|
const left = Math.min(...rects.map(r => r.left));
|
|
const top = Math.min(...rects.map(r => r.top));
|
|
const right = Math.max(...rects.map(r => r.right));
|
|
const bottom = Math.max(...rects.map(r => r.bottom));
|
|
return {
|
|
left,
|
|
top,
|
|
right,
|
|
bottom,
|
|
width: right - left,
|
|
height: bottom - top,
|
|
x: left,
|
|
y: top,
|
|
};
|
|
}
|
|
|
|
function collectVisualContrastReasons(el, style) {
|
|
const reasons = new Set();
|
|
const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
|
|
const ownBgImage = style.backgroundImage || '';
|
|
if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {
|
|
reasons.add('background-clip text');
|
|
}
|
|
if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');
|
|
|
|
let current = el;
|
|
while (current && current.nodeType === 1) {
|
|
const tag = current.tagName?.toLowerCase();
|
|
const currentStyle = getComputedStyle(current);
|
|
const bgImage = currentStyle.backgroundImage || '';
|
|
const isDocumentSurface = tag === 'body' || tag === 'html';
|
|
|
|
if (!isDocumentSurface && bgImage && bgImage !== 'none') {
|
|
if (/url\s*\(/i.test(bgImage)) reasons.add('image background');
|
|
if (/gradient/i.test(bgImage)) reasons.add('gradient background');
|
|
}
|
|
if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');
|
|
if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');
|
|
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
|
|
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
|
|
|
|
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
|
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
|
current = current.parentElement;
|
|
}
|
|
|
|
const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();
|
|
if (sampleRect && document.elementsFromPoint) {
|
|
const points = [
|
|
[sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],
|
|
[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],
|
|
[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],
|
|
];
|
|
for (const [x, y] of points) {
|
|
if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
|
|
const stack = document.elementsFromPoint(x, y);
|
|
const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));
|
|
if (selfIndex < 0) continue;
|
|
for (const node of stack.slice(selfIndex + 1)) {
|
|
const nodeTag = node.tagName?.toLowerCase();
|
|
if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {
|
|
reasons.add(`${nodeTag} underlay`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...reasons];
|
|
}
|
|
|
|
function collectVisualContrastCandidates(options = {}) {
|
|
const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;
|
|
const candidates = [];
|
|
for (const el of document.querySelectorAll('*')) {
|
|
if (candidates.length >= maxCandidates) break;
|
|
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
|
if (el.closest('[id^="impeccable-live-"]')) continue;
|
|
if (el === document.body || el === document.documentElement) continue;
|
|
if (!isRenderedForBrowserRule(el)) continue;
|
|
|
|
const tag = el.tagName.toLowerCase();
|
|
const style = getComputedStyle(el);
|
|
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
const directText = getDirectText(el);
|
|
const hasDirectText = directText.trim().length > 0;
|
|
if (!hasDirectText || isEmojiOnlyText(directText)) continue;
|
|
|
|
const bgColor = readOwnBackgroundColor(el, style);
|
|
const isStyledButton = (tag === 'a' || tag === 'button')
|
|
&& bgColor && bgColor.a > 0.5;
|
|
if (SAFE_TAGS.has(tag) && !isStyledButton) continue;
|
|
|
|
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
|
if (!rect || rect.width < 4 || rect.height < 4) continue;
|
|
|
|
const reasons = collectVisualContrastReasons(el, style);
|
|
if (reasons.length === 0) continue;
|
|
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
|
// candidates earlier in DOM order must not consume the budget and
|
|
// starve the url()-backed texts this mode exists to sample.
|
|
if (options.imageOnly && !reasons.includes('image background')) continue;
|
|
|
|
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
|
const fontSize = parseFloat(style.fontSize) || 16;
|
|
const fontWeight = parseInt(style.fontWeight) || 400;
|
|
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
|
const threshold = isLargeText ? 3.0 : 4.5;
|
|
const clip = {
|
|
x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),
|
|
y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),
|
|
width: Math.max(1, Math.ceil(rect.width + 4)),
|
|
height: Math.max(1, Math.ceil(rect.height + 4)),
|
|
};
|
|
|
|
candidates.push({
|
|
selector: generateSelector(el),
|
|
tagName: tag,
|
|
text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),
|
|
threshold,
|
|
reasons,
|
|
clip,
|
|
textColor,
|
|
preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>
|
|
reason === 'opacity stack' ||
|
|
reason === 'blend mode' ||
|
|
reason === 'filter' ||
|
|
reason === 'backdrop filter' ||
|
|
reason === 'background-clip text'
|
|
),
|
|
backgroundClipText: reasons.includes('background-clip text'),
|
|
});
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
const visualContrastImageCache = new Map();
|
|
const visualContrastRasterCache = new WeakMap();
|
|
|
|
function clampByte(value) {
|
|
return Math.max(0, Math.min(255, Math.round(value)));
|
|
}
|
|
|
|
function blendRgba(fg, bg) {
|
|
if (!fg) return bg || null;
|
|
if (!bg || fg.a == null || fg.a >= 0.999) {
|
|
return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };
|
|
}
|
|
const alpha = Math.max(0, Math.min(1, fg.a));
|
|
return {
|
|
r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),
|
|
g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),
|
|
b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),
|
|
a: 1,
|
|
};
|
|
}
|
|
|
|
function pickWorstContrastColor(textColor, colors) {
|
|
const usable = (colors || []).filter(Boolean);
|
|
if (!usable.length) return null;
|
|
let worst = usable[0];
|
|
let worstRatio = contrastRatio(textColor, worst);
|
|
for (const color of usable.slice(1)) {
|
|
const ratio = contrastRatio(textColor, color);
|
|
if (ratio < worstRatio) {
|
|
worst = color;
|
|
worstRatio = ratio;
|
|
}
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
function firstCssUrl(value) {
|
|
const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);
|
|
if (!match) return '';
|
|
return (match[1] || match[2] || match[3] || '').trim();
|
|
}
|
|
|
|
function getLayerValue(value, index = 0) {
|
|
return String(value || '').split(',')[index]?.trim() || '';
|
|
}
|
|
|
|
function parsePositionToken(token, container, painted) {
|
|
if (!token || token === 'center') return (container - painted) / 2;
|
|
if (token === 'left' || token === 'top') return 0;
|
|
if (token === 'right' || token === 'bottom') return container - painted;
|
|
if (/%$/.test(token)) {
|
|
const pct = parseFloat(token) / 100;
|
|
return (container - painted) * pct;
|
|
}
|
|
if (/px$/.test(token)) return parseFloat(token) || 0;
|
|
return (container - painted) / 2;
|
|
}
|
|
|
|
function parsePositionPair(positionValue) {
|
|
const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);
|
|
const first = tokens[0] || '50%';
|
|
if (tokens.length < 2) {
|
|
if (first === 'top' || first === 'bottom') return ['50%', first];
|
|
return [first, '50%'];
|
|
}
|
|
return [first, tokens[1] || '50%'];
|
|
}
|
|
|
|
function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {
|
|
const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
|
|
const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
|
|
let paintedWidth = intrinsicWidth;
|
|
let paintedHeight = intrinsicHeight;
|
|
const size = String(sizeValue || 'auto').trim();
|
|
|
|
if (size === 'cover' || size === 'contain') {
|
|
const scale = size === 'cover'
|
|
? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
|
|
: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
|
|
paintedWidth = intrinsicWidth * scale;
|
|
paintedHeight = intrinsicHeight * scale;
|
|
} else if (size && size !== 'auto') {
|
|
const parts = size.split(/\s+/);
|
|
const widthToken = parts[0];
|
|
const heightToken = parts[1] || 'auto';
|
|
if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);
|
|
else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;
|
|
if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);
|
|
else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);
|
|
else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;
|
|
}
|
|
|
|
const [xToken, yToken] = parsePositionPair(positionValue);
|
|
const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);
|
|
const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);
|
|
return {
|
|
left: containerRect.left + positionX,
|
|
top: containerRect.top + positionY,
|
|
width: paintedWidth,
|
|
height: paintedHeight,
|
|
intrinsicWidth,
|
|
intrinsicHeight,
|
|
};
|
|
}
|
|
|
|
function parseObjectPosition(positionValue) {
|
|
return parsePositionPair(positionValue);
|
|
}
|
|
|
|
function resolveObjectImageRect(containerRect, image, style) {
|
|
const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
|
|
const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
|
|
const fit = style.objectFit || 'fill';
|
|
let paintedWidth = containerRect.width;
|
|
let paintedHeight = containerRect.height;
|
|
if (fit === 'contain' || fit === 'cover') {
|
|
const scale = fit === 'cover'
|
|
? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
|
|
: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
|
|
paintedWidth = intrinsicWidth * scale;
|
|
paintedHeight = intrinsicHeight * scale;
|
|
} else if (fit === 'none') {
|
|
paintedWidth = intrinsicWidth;
|
|
paintedHeight = intrinsicHeight;
|
|
} else if (fit === 'scale-down') {
|
|
const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);
|
|
paintedWidth = intrinsicWidth * containScale;
|
|
paintedHeight = intrinsicHeight * containScale;
|
|
}
|
|
const [xToken, yToken] = parseObjectPosition(style.objectPosition);
|
|
return {
|
|
left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),
|
|
top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),
|
|
width: paintedWidth,
|
|
height: paintedHeight,
|
|
intrinsicWidth,
|
|
intrinsicHeight,
|
|
};
|
|
}
|
|
|
|
function pointToImageSource(point, paintedRect) {
|
|
if (
|
|
point.x < paintedRect.left ||
|
|
point.y < paintedRect.top ||
|
|
point.x > paintedRect.left + paintedRect.width ||
|
|
point.y > paintedRect.top + paintedRect.height
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),
|
|
y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),
|
|
};
|
|
}
|
|
|
|
async function loadVisualContrastImage(src) {
|
|
if (!src) return null;
|
|
if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);
|
|
const promise = new Promise(resolve => {
|
|
const img = new Image();
|
|
let settled = false;
|
|
const finish = value => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
};
|
|
const timer = setTimeout(() => finish(null), 800);
|
|
try {
|
|
const absolute = new URL(src, location.href);
|
|
if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {
|
|
img.crossOrigin = 'anonymous';
|
|
}
|
|
} catch {
|
|
// Let the browser resolve unusual URLs itself.
|
|
}
|
|
img.onload = () => finish(img);
|
|
img.onerror = () => finish(null);
|
|
img.src = src;
|
|
});
|
|
visualContrastImageCache.set(src, promise);
|
|
return promise;
|
|
}
|
|
|
|
function sampleDrawablePixel(drawable, sourcePoint) {
|
|
if (visualContrastRasterCache.has(drawable)) {
|
|
const cached = visualContrastRasterCache.get(drawable);
|
|
if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };
|
|
try {
|
|
const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
|
|
const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
|
|
const data = cached.ctx.getImageData(x, y, 1, 1).data;
|
|
return {
|
|
status: 'sampled',
|
|
color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
status: 'unresolved',
|
|
reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',
|
|
};
|
|
}
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;
|
|
const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;
|
|
const maxRasterSide = 640;
|
|
const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));
|
|
canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));
|
|
canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };
|
|
try {
|
|
ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);
|
|
const cached = {
|
|
ctx,
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
scaleX: canvas.width / intrinsicWidth,
|
|
scaleY: canvas.height / intrinsicHeight,
|
|
};
|
|
visualContrastRasterCache.set(drawable, cached);
|
|
const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
|
|
const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
|
|
const data = ctx.getImageData(x, y, 1, 1).data;
|
|
return {
|
|
status: 'sampled',
|
|
color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
|
|
};
|
|
} catch (err) {
|
|
const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';
|
|
visualContrastRasterCache.set(drawable, { ctx: null, reason });
|
|
return {
|
|
status: 'unresolved',
|
|
reason,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function sampleCssBackground(el, style, point, textColor) {
|
|
const rect = el.getBoundingClientRect();
|
|
const bgImage = style.backgroundImage || '';
|
|
if (bgImage && bgImage !== 'none') {
|
|
if (/gradient/i.test(bgImage)) {
|
|
const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));
|
|
if (color) return { status: 'sampled', color, method: 'analytic-gradient' };
|
|
}
|
|
if (/url\s*\(/i.test(bgImage)) {
|
|
const img = await loadVisualContrastImage(firstCssUrl(bgImage));
|
|
if (!img) return { status: 'unresolved', reason: 'image unavailable' };
|
|
const paintedRect = resolvePaintedImageRect(
|
|
rect,
|
|
img,
|
|
getLayerValue(style.backgroundSize) || 'auto',
|
|
getLayerValue(style.backgroundPosition) || '50% 50%',
|
|
);
|
|
const sourcePoint = pointToImageSource(point, paintedRect);
|
|
if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };
|
|
const sample = sampleDrawablePixel(img, sourcePoint);
|
|
if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };
|
|
return sample;
|
|
}
|
|
}
|
|
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
|
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
|
return { status: 'unresolved', reason: 'no readable background' };
|
|
}
|
|
|
|
async function sampleImageElement(img, point) {
|
|
const rect = img.getBoundingClientRect();
|
|
const style = getComputedStyle(img);
|
|
const paintedRect = resolveObjectImageRect(rect, img, style);
|
|
const sourcePoint = pointToImageSource(point, paintedRect);
|
|
if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };
|
|
const sample = sampleDrawablePixel(img, sourcePoint);
|
|
if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };
|
|
|
|
if (img.currentSrc || img.src) {
|
|
const loaded = await loadVisualContrastImage(img.currentSrc || img.src);
|
|
if (loaded) {
|
|
const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };
|
|
const loadedPoint = pointToImageSource(point, loadedRect);
|
|
if (loadedPoint) {
|
|
const loadedSample = sampleDrawablePixel(loaded, loadedPoint);
|
|
if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };
|
|
}
|
|
}
|
|
}
|
|
return sample;
|
|
}
|
|
|
|
function textSamplePoints(rect) {
|
|
const insetX = Math.min(12, Math.max(1, rect.width * 0.12));
|
|
const insetY = Math.min(8, Math.max(1, rect.height * 0.22));
|
|
const xs = rect.width < 28
|
|
? [rect.left + rect.width / 2]
|
|
: [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];
|
|
const ys = rect.height < 22
|
|
? [rect.top + rect.height / 2]
|
|
: [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];
|
|
const points = [];
|
|
for (const y of ys) {
|
|
for (const x of xs) {
|
|
if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });
|
|
}
|
|
}
|
|
return points;
|
|
}
|
|
|
|
async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {
|
|
if (depth > 8) {
|
|
return { status: 'unresolved', reason: 'background stack too deep' };
|
|
}
|
|
const stack = typeof document.elementsFromPoint === 'function'
|
|
? document.elementsFromPoint(point.x, point.y)
|
|
: [];
|
|
const selfIndex = stack.findIndex(node => node === el || el.contains(node));
|
|
const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];
|
|
const unresolved = [];
|
|
|
|
for (const node of nodes) {
|
|
if (!node || node.nodeType !== 1) continue;
|
|
if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
|
const tag = node.tagName?.toLowerCase();
|
|
if (tag === 'img') {
|
|
const sample = await sampleImageElement(node, point);
|
|
if (sample.status === 'sampled') return sample;
|
|
unresolved.push(sample.reason);
|
|
continue;
|
|
}
|
|
if (tag === 'canvas' || tag === 'video') {
|
|
const rect = node.getBoundingClientRect();
|
|
const sourcePoint = pointToImageSource(point, {
|
|
left: rect.left,
|
|
top: rect.top,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
intrinsicWidth: node.width || node.videoWidth || rect.width,
|
|
intrinsicHeight: node.height || node.videoHeight || rect.height,
|
|
});
|
|
if (sourcePoint) {
|
|
const sample = sampleDrawablePixel(node, sourcePoint);
|
|
if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };
|
|
unresolved.push(sample.reason);
|
|
}
|
|
continue;
|
|
}
|
|
const style = getComputedStyle(node);
|
|
const sample = await sampleCssBackground(node, style, point, textColor);
|
|
if (sample.status === 'sampled') {
|
|
if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;
|
|
const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);
|
|
if (under.status === 'sampled') {
|
|
return {
|
|
status: 'sampled',
|
|
color: blendRgba(sample.color, under.color),
|
|
method: `${sample.method}+alpha`,
|
|
};
|
|
}
|
|
return sample;
|
|
}
|
|
unresolved.push(sample.reason);
|
|
}
|
|
|
|
return {
|
|
status: 'unresolved',
|
|
reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',
|
|
};
|
|
}
|
|
|
|
async function analyzeVisualContrastCandidate(candidate) {
|
|
let el;
|
|
try {
|
|
el = document.querySelector(candidate.selector);
|
|
} catch {
|
|
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
|
}
|
|
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
|
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
|
|
|
const blockingReason = (candidate.reasons || []).find(reason =>
|
|
reason === 'background-clip text' ||
|
|
reason === 'blend mode' ||
|
|
reason === 'filter' ||
|
|
reason === 'backdrop filter' ||
|
|
reason === 'opacity stack' ||
|
|
reason === 'text shadow'
|
|
);
|
|
if (blockingReason) {
|
|
return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };
|
|
}
|
|
|
|
const style = getComputedStyle(el);
|
|
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
|
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
|
|
|
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
|
if (!rect || rect.width < 4 || rect.height < 4) {
|
|
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };
|
|
}
|
|
|
|
const points = textSamplePoints(rect);
|
|
if (points.length === 0) {
|
|
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };
|
|
}
|
|
|
|
const ratios = [];
|
|
const methods = new Set();
|
|
const unresolved = [];
|
|
for (const point of points) {
|
|
const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);
|
|
if (sample.status !== 'sampled' || !sample.color) {
|
|
unresolved.push(sample.reason);
|
|
continue;
|
|
}
|
|
const fg = blendRgba(textColor, sample.color);
|
|
ratios.push(contrastRatio(fg, sample.color));
|
|
if (sample.method) methods.add(sample.method);
|
|
}
|
|
|
|
if (ratios.length < Math.min(3, points.length)) {
|
|
return {
|
|
...candidate,
|
|
status: 'unresolved',
|
|
confidence: 'none',
|
|
samples: ratios.length,
|
|
reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',
|
|
};
|
|
}
|
|
|
|
ratios.sort((a, b) => a - b);
|
|
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
|
|
const measuredRatio = pick(10);
|
|
const medianRatio = pick(50);
|
|
const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';
|
|
const method = [...methods].sort().join(', ') || 'browser-visual';
|
|
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
|
|
const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;
|
|
return {
|
|
...candidate,
|
|
status,
|
|
confidence: method.includes('canvas-') ? 'high' : 'medium',
|
|
method,
|
|
ratio: measuredRatio,
|
|
medianRatio,
|
|
samples: ratios.length,
|
|
finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,
|
|
};
|
|
}
|
|
|
|
function waitForVisualPaint() {
|
|
return new Promise(resolve => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
});
|
|
}
|
|
|
|
async function analyzeVisualContrast(options = {}) {
|
|
// imageOnly is enforced inside the collector, before the candidate cap.
|
|
const candidates = collectVisualContrastCandidates(options);
|
|
const results = [];
|
|
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
|
const restoreScroll = { x: window.scrollX, y: window.scrollY };
|
|
for (const candidate of candidates) {
|
|
if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
|
|
window.scrollTo(restoreScroll.x, restoreScroll.y);
|
|
await waitForVisualPaint();
|
|
}
|
|
let result = await analyzeVisualContrastCandidate(candidate);
|
|
if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {
|
|
let el = null;
|
|
try {
|
|
el = document.querySelector(candidate.selector);
|
|
} catch {
|
|
el = null;
|
|
}
|
|
if (el && typeof el.scrollIntoView === 'function') {
|
|
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
|
|
await waitForVisualPaint();
|
|
result = await analyzeVisualContrastCandidate(candidate);
|
|
}
|
|
}
|
|
results.push(result);
|
|
}
|
|
if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
|
|
window.scrollTo(restoreScroll.x, restoreScroll.y);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
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) {
|
|
return allFindings.map(({ el, findings }) => ({
|
|
selector: generateSelector(el),
|
|
tagName: el.tagName?.toLowerCase() || 'unknown',
|
|
rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
|
|
? el.getBoundingClientRect().toJSON() : null,
|
|
isPageLevel: el === document.body || el === document.documentElement,
|
|
isHidden: isElementHidden(el),
|
|
findings: findings.map(f => {
|
|
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
|
|
const severity = f.severity || ap?.severity || 'warning';
|
|
return {
|
|
type: f.type || f.id,
|
|
category: ap ? ap.category : 'quality',
|
|
severity,
|
|
// Advisory findings (em-dash overuse, etc.) are surfaced but never
|
|
// treated as failures; carry the flag so the overlay/extension can
|
|
// render them with the mildest affordance and consumers can filter.
|
|
// Per-finding promotions override the registry default, so derive
|
|
// this strictly from the effective severity.
|
|
advisory: severity === 'advisory',
|
|
detail: f.detail || f.snippet,
|
|
ignoreValue: f.ignoreValue || f.value || '',
|
|
name: ap ? ap.name : (f.type || f.id),
|
|
description: ap ? ap.description : '',
|
|
};
|
|
}),
|
|
}));
|
|
}
|
|
|
|
const printSummary = function(allFindings) {
|
|
if (allFindings.length === 0) {
|
|
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
|
|
return;
|
|
}
|
|
console.group(
|
|
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
|
|
'color: oklch(84% 0.19 80.46); font-weight: bold'
|
|
);
|
|
for (const { el, findings } of allFindings) {
|
|
for (const f of findings) {
|
|
console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
|
|
'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);
|
|
}
|
|
}
|
|
console.groupEnd();
|
|
};
|
|
|
|
function addBrowserFindings(groupMap, el, findings) {
|
|
if (!findings || findings.length === 0) return;
|
|
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
|
// matching findings for its whole subtree. Applied at this choke point so
|
|
// every per-element attribution (checks, layout, occlusion, rhythm)
|
|
// honors it; page-level findings attributed to <body> pass through
|
|
// untouched, since body has no ignoring ancestor.
|
|
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
|
if (kept.length === 0) return;
|
|
const existing = groupMap.get(el);
|
|
if (existing) existing.push(...kept);
|
|
else groupMap.set(el, [...kept]);
|
|
}
|
|
|
|
function pseudoElementHostSelector(selector) {
|
|
const raw = String(selector || '');
|
|
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
|
|
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
|
|
const consumeFunction = (start) => {
|
|
let depth = 0;
|
|
let quote = '';
|
|
for (let i = start; i < raw.length; i += 1) {
|
|
const char = raw[i];
|
|
if (char === '\\') {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
if (char === quote) quote = '';
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
continue;
|
|
}
|
|
if (char === '(') depth += 1;
|
|
if (char === ')' && --depth === 0) return i + 1;
|
|
}
|
|
return raw.length;
|
|
};
|
|
|
|
let output = '';
|
|
let found = false;
|
|
for (let i = 0; i < raw.length;) {
|
|
const char = raw[i];
|
|
if (char === '\\') {
|
|
output += raw.slice(i, Math.min(raw.length, i + 2));
|
|
i += 2;
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
const quote = char;
|
|
const start = i;
|
|
i += 1;
|
|
while (i < raw.length) {
|
|
if (raw[i] === '\\') {
|
|
i += 2;
|
|
continue;
|
|
}
|
|
const value = raw[i];
|
|
i += 1;
|
|
if (value === quote) break;
|
|
}
|
|
output += raw.slice(start, i);
|
|
continue;
|
|
}
|
|
if (char !== ':') {
|
|
output += char;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
let end = i + 1;
|
|
let isPseudoElement = false;
|
|
if (raw[end] === ':') {
|
|
end += 1;
|
|
const nameStart = end;
|
|
while (isNameChar(raw[end])) end += 1;
|
|
isPseudoElement = end > nameStart;
|
|
} else {
|
|
const nameStart = end;
|
|
while (isNameChar(raw[end])) end += 1;
|
|
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
|
|
}
|
|
if (!isPseudoElement) {
|
|
output += char;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if (raw[end] === '(') end = consumeFunction(end);
|
|
found = true;
|
|
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
|
|
i = end;
|
|
}
|
|
if (!found) return null;
|
|
return output.trim().replace(/,\s*(?=,|$)/g, '');
|
|
}
|
|
|
|
function selectorNodesForLiveDom(root, selector) {
|
|
const raw = String(selector || '').trim();
|
|
if (!raw) return null;
|
|
const fallback = pseudoElementHostSelector(raw);
|
|
if (fallback == null) {
|
|
// An empty result from a valid full selector is authoritative. In
|
|
// particular, do not broaden inactive :hover/:focus/:not() rules to
|
|
// their host element by stripping pseudo-classes.
|
|
try { return Array.from(root.querySelectorAll(raw)); }
|
|
catch { return null; }
|
|
}
|
|
|
|
// Resolve pseudo-elements to their originating live elements. An attached
|
|
// pseudo-element (`.card::before`) belongs to the element before it, while
|
|
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
|
|
// to a matching element at that position (`main > *`). Replacing every
|
|
// pseudo indiscriminately with an empty string leaves the latter as the
|
|
// invalid selector `main >` and makes absent hosts indistinguishable from
|
|
// selectors the DOM API cannot parse.
|
|
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
|
|
try { return Array.from(root.querySelectorAll(fallback)); }
|
|
catch { return null; }
|
|
}
|
|
|
|
let containerProbeSequence = 0;
|
|
|
|
function isContainerCssRule(rule) {
|
|
return rule?.constructor?.name === 'CSSContainerRule'
|
|
|| /^\s*@container\b/i.test(rule?.cssText || '');
|
|
}
|
|
|
|
function styleRuleAppliesToLiveMatches(rule, matches) {
|
|
const style = rule?.style;
|
|
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
|
|
const sequence = ++containerProbeSequence;
|
|
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
|
|
const value = `impeccable-container-active-${sequence}`;
|
|
const previousValue = style.getPropertyValue(property);
|
|
const previousPriority = style.getPropertyPriority(property);
|
|
try {
|
|
style.setProperty(property, value, 'important');
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const pseudoElements = [...new Set(
|
|
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
|
|
)];
|
|
try {
|
|
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
|
|
try {
|
|
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
|
|
return computed.getPropertyValue(property).trim() === value;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}));
|
|
} finally {
|
|
if (previousValue) style.setProperty(property, previousValue, previousPriority);
|
|
else style.removeProperty(property);
|
|
}
|
|
}
|
|
|
|
function conditionalCssRuleIsActive(rule) {
|
|
const type = Number(rule?.type);
|
|
const constructorName = rule?.constructor?.name || '';
|
|
if (constructorName === 'CSSMediaRule' || type === 4) {
|
|
const condition = rule.conditionText || rule.media?.mediaText || '';
|
|
if (!condition || typeof window.matchMedia !== 'function') return true;
|
|
try { return window.matchMedia(condition).matches; }
|
|
catch { return true; }
|
|
}
|
|
if (constructorName === 'CSSSupportsRule' || type === 12) {
|
|
const condition = rule.conditionText || '';
|
|
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
|
|
try { return CSS.supports(condition); }
|
|
catch { return true; }
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function splitCssCommaList(value) {
|
|
const parts = [];
|
|
let current = '';
|
|
let quote = '';
|
|
let escaped = false;
|
|
for (const char of String(value || '')) {
|
|
if (escaped) {
|
|
current += char;
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
if (char === '\\') {
|
|
current += char;
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
if (quote) {
|
|
current += char;
|
|
if (char === quote) quote = '';
|
|
continue;
|
|
}
|
|
if (char === '"' || char === "'") {
|
|
quote = char;
|
|
current += char;
|
|
continue;
|
|
}
|
|
if (char === ',') {
|
|
parts.push(current);
|
|
current = '';
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
parts.push(current);
|
|
return parts;
|
|
}
|
|
|
|
function normalizeAnimationName(value) {
|
|
const name = String(value || '').trim();
|
|
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
|
|
return name.slice(1, -1);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
function animationNamesDeclaredByRule(rule) {
|
|
const style = rule?.style;
|
|
if (!style) return [];
|
|
let value = '';
|
|
try {
|
|
value = style.animationName
|
|
|| style.getPropertyValue?.('animation-name')
|
|
|| style.webkitAnimationName
|
|
|| style.getPropertyValue?.('-webkit-animation-name')
|
|
|| '';
|
|
} catch {
|
|
return [];
|
|
}
|
|
return splitCssCommaList(value)
|
|
.map(normalizeAnimationName)
|
|
.filter(name => name && name.toLowerCase() !== 'none');
|
|
}
|
|
|
|
function keyframesRuleName(rule, cssText) {
|
|
const constructorName = rule?.constructor?.name || '';
|
|
const type = Number(rule?.type);
|
|
const isKeyframes = constructorName === 'CSSKeyframesRule'
|
|
|| constructorName === 'WebKitCSSKeyframesRule'
|
|
|| type === 7
|
|
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
|
|
if (!isKeyframes) return '';
|
|
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
|
|
return normalizeAnimationName(rule?.name || match?.[1] || '');
|
|
}
|
|
|
|
function cssPropertyName(property) {
|
|
if (property.startsWith('--')) return property;
|
|
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
|
}
|
|
|
|
function resolvedAnimationKeyframes(candidateNames) {
|
|
if (typeof document.getAnimations !== 'function') return null;
|
|
let animations;
|
|
try { animations = document.getAnimations(); }
|
|
catch { return null; }
|
|
|
|
const resolved = new Map();
|
|
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
|
|
for (const animation of animations) {
|
|
const name = normalizeAnimationName(animation?.animationName || '');
|
|
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
|
|
let frames;
|
|
try { frames = animation.effect?.getKeyframes?.() || []; }
|
|
catch { continue; }
|
|
const blocks = [];
|
|
for (const frame of frames) {
|
|
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
|
|
if (!Number.isFinite(rawOffset)) continue;
|
|
const offset = Math.round(rawOffset * 1000000) / 10000;
|
|
const declarations = Object.entries(frame)
|
|
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
|
|
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
|
|
const easing = String(frame.easing || '').trim();
|
|
if (easing && easing.toLowerCase() !== 'linear') {
|
|
declarations.push(`animation-timing-function: ${easing};`);
|
|
}
|
|
if (declarations.length === 0) continue;
|
|
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
|
|
}
|
|
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
|
|
// already present in the HTML pattern corpus, so limit this walk to linked
|
|
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
|
|
// and admit only selector rules that target the live DOM. That prevents
|
|
// unused utilities from feeding both selector-scoped and page-level checks.
|
|
// Same-origin CSS and readable CORS sheets participate; browser security
|
|
// exceptions for cross-origin sheets are expected and skipped.
|
|
function linkedStylesheetText() {
|
|
const parts = [];
|
|
const seen = new Set();
|
|
const animationNames = new Set();
|
|
const keyframeCandidates = new Map();
|
|
const appendRules = (rules, requiresAppliedMatch = false) => {
|
|
for (const rule of rules) {
|
|
if (rule.styleSheet) {
|
|
appendSheet(rule.styleSheet);
|
|
continue;
|
|
}
|
|
const cssText = rule.cssText || '';
|
|
if (rule.selectorText) {
|
|
const matches = selectorNodesForLiveDom(document, rule.selectorText);
|
|
// Only declarations with a resolvable live host enter the corpus.
|
|
// Unresolvable selectors are uncertain, not evidence that a pattern
|
|
// rendered, and retaining them would leak unused CSS into findings.
|
|
if (
|
|
matches?.length > 0
|
|
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
|
|
) {
|
|
parts.push(cssText);
|
|
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
|
|
}
|
|
continue;
|
|
}
|
|
let nested = [];
|
|
let hasNestedRules = false;
|
|
try {
|
|
const ruleList = rule.cssRules;
|
|
hasNestedRules = ruleList != null;
|
|
nested = Array.from(ruleList || []);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const keyframesName = keyframesRuleName(rule, cssText);
|
|
if (keyframesName) {
|
|
// Keyframes do not merge: when a name is defined more than once, the
|
|
// later effective definition replaces the earlier one.
|
|
keyframeCandidates.set(keyframesName, {
|
|
name: keyframesName,
|
|
cssText,
|
|
});
|
|
continue;
|
|
}
|
|
if (hasNestedRules) {
|
|
if (!conditionalCssRuleIsActive(rule)) continue;
|
|
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
|
|
continue;
|
|
}
|
|
// Other selector-less leaf at-rules cannot be tied to a rendered node.
|
|
}
|
|
};
|
|
const appendSheet = (sheet) => {
|
|
if (!sheet || seen.has(sheet)) return;
|
|
seen.add(sheet);
|
|
let rules;
|
|
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
|
|
catch { return; }
|
|
appendRules(rules);
|
|
};
|
|
let sheets;
|
|
try { sheets = Array.from(document.styleSheets || []); }
|
|
catch { return ''; }
|
|
for (const sheet of sheets) {
|
|
const owner = sheet.ownerNode;
|
|
if (owner?.tagName?.toLowerCase() !== 'link') continue;
|
|
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
|
|
appendSheet(sheet);
|
|
}
|
|
// Motion checks need the effective body of a live animation's keyframes.
|
|
// Let the browser resolve duplicate names across source order, imports,
|
|
// conditional groups, and cascade layers, then serialize those computed
|
|
// frames back into the pattern corpus. Browsers also make container-nested
|
|
// keyframes globally available, so lexical grouping is not a reliable
|
|
// activity signal. When the Web Animations API is unavailable, fall back to
|
|
// the last source-order definition referenced by a retained linked rule.
|
|
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
|
|
if (resolvedKeyframes) {
|
|
parts.push(...resolvedKeyframes.values());
|
|
} else {
|
|
for (const candidate of keyframeCandidates.values()) {
|
|
if (!animationNames.has(candidate.name)) continue;
|
|
parts.push(candidate.cssText);
|
|
}
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
|
|
function browserFindingsFromMap(groupMap) {
|
|
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
|
}
|
|
|
|
const DESIGN_COLOR_TOLERANCE = 6;
|
|
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
|
|
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
|
|
|
function normalizeBrowserFontName(value) {
|
|
return String(value || '')
|
|
.trim()
|
|
.replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.toLowerCase();
|
|
}
|
|
|
|
function browserPrimaryFont(stack) {
|
|
if (!stack || /var\(/i.test(stack)) return '';
|
|
return String(stack || '')
|
|
.split(',')
|
|
.map(normalizeBrowserFontName)
|
|
.find(font => font && !GENERIC_FONTS.has(font)) || '';
|
|
}
|
|
|
|
function browserDesignSystemConfig() {
|
|
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
|
|
if (!raw?.present) return null;
|
|
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
|
|
const allowedColors = (raw.allowedColors || [])
|
|
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
|
.map(color => ({ r: color.r, g: color.g, b: color.b }));
|
|
const allowedRadii = (raw.allowedRadii || [])
|
|
.map(Number)
|
|
.filter(px => Number.isFinite(px));
|
|
return {
|
|
present: true,
|
|
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
|
|
allowedFonts,
|
|
hasColors: raw.hasColors === true && allowedColors.length > 0,
|
|
allowedColors,
|
|
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
|
|
allowedRadii,
|
|
hasPillRadius: raw.hasPillRadius === true,
|
|
};
|
|
}
|
|
|
|
function browserColorsClose(a, b) {
|
|
if (!a || !b) return false;
|
|
return Math.max(
|
|
Math.abs(a.r - b.r),
|
|
Math.abs(a.g - b.g),
|
|
Math.abs(a.b - b.b),
|
|
) <= DESIGN_COLOR_TOLERANCE;
|
|
}
|
|
|
|
function isBrowserDesignColorAllowed(raw, designSystem) {
|
|
if (!designSystem?.hasColors) return true;
|
|
const text = String(raw || '').trim().toLowerCase();
|
|
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
|
|
if (text.includes('var(')) return true;
|
|
const parsed = parseAnyColor(text);
|
|
if (!parsed) return true;
|
|
if ((parsed.a ?? 1) <= 0.05) return true;
|
|
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
|
|
}
|
|
|
|
function isBrowserTransparentCss(value) {
|
|
const text = String(value || '').trim().toLowerCase();
|
|
if (!text || text === 'transparent') return true;
|
|
const parsed = parseAnyColor(text);
|
|
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
|
}
|
|
|
|
function isBrowserDesignRadiusAllowed(raw, designSystem) {
|
|
if (!designSystem?.hasRadii) return true;
|
|
const text = String(raw || '').trim().toLowerCase();
|
|
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
|
|
if (text.includes('var(') || text.includes('%')) return true;
|
|
const px = resolveLengthPx(text, 16);
|
|
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
|
|
if (designSystem.hasPillRadius && px >= 99) return true;
|
|
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
|
|
}
|
|
|
|
function browserRadiusTokens(value) {
|
|
return String(value || '')
|
|
.replace(/\s*\/\s*/g, ' ')
|
|
.split(/\s+/)
|
|
.map(token => token.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function browserHasDirectText(el) {
|
|
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
|
}
|
|
|
|
function browserSampleText(el) {
|
|
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
return text ? ` "${text.slice(0, 40)}"` : '';
|
|
}
|
|
|
|
function shouldSkipDesignElement(el) {
|
|
const tag = el.tagName?.toLowerCase?.() || '';
|
|
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
|
|
}
|
|
|
|
function checkElementDesignSystemDOM(el, designSystem, seen) {
|
|
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
|
|
const findings = [];
|
|
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
|
const style = getComputedStyle(el);
|
|
|
|
if (designSystem.hasFonts && browserHasDirectText(el)) {
|
|
const font = browserPrimaryFont(style.fontFamily || '');
|
|
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
|
|
seen.fonts.add(font);
|
|
findings.push({
|
|
type: 'design-system-font',
|
|
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
|
ignoreValue: font,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (designSystem.hasColors) {
|
|
const colorChecks = [];
|
|
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
|
|
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
|
|
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
|
|
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
|
|
}
|
|
}
|
|
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
|
|
|
|
for (const [kind, raw] of colorChecks) {
|
|
const label = String(raw || '').trim().replace(/\s+/g, ' ');
|
|
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
|
|
const key = `${kind}:${label}`;
|
|
if (seen.colors.has(key)) continue;
|
|
seen.colors.add(key);
|
|
findings.push({
|
|
type: 'design-system-color',
|
|
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
|
|
ignoreValue: label,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (designSystem.hasRadii) {
|
|
for (const token of browserRadiusTokens(style.borderRadius || '')) {
|
|
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
|
|
if (seen.radii.has(token)) continue;
|
|
seen.radii.add(token);
|
|
findings.push({
|
|
type: 'design-system-radius',
|
|
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
|
|
ignoreValue: token,
|
|
});
|
|
}
|
|
}
|
|
|
|
return findings;
|
|
}
|
|
|
|
function decodeBrowserGoogleFamily(value) {
|
|
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
|
try {
|
|
return decodeURIComponent(family);
|
|
} catch {
|
|
return family;
|
|
}
|
|
}
|
|
|
|
function checkBrowserDesignSystemSources(designSystem, seen) {
|
|
if (!designSystem?.hasFonts) return [];
|
|
const findings = [];
|
|
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
|
|
const href = link.getAttribute('href') || '';
|
|
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
|
|
const display = decodeBrowserGoogleFamily(match[1]);
|
|
const font = normalizeBrowserFontName(display);
|
|
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
|
|
seen.fonts.add(font);
|
|
findings.push({
|
|
type: 'design-system-font',
|
|
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
|
ignoreValue: display,
|
|
});
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
// A page matched by detector.ignoreFiles is waived wholesale: every scan
|
|
// stage answers empty so the badge and toast read zero. Mirrors
|
|
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
|
|
// overlay resolves the globs per page (live-browser-ignores.js) and
|
|
// forwards the verdict as config.skipScan.
|
|
function skipScanActive() {
|
|
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
|
|
}
|
|
|
|
function collectBrowserFindings() {
|
|
if (skipScanActive()) {
|
|
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
|
|
}
|
|
const groupMap = new Map();
|
|
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
|
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
|
const designSystem = browserDesignSystemConfig();
|
|
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
|
|
// All deterministic rules run in the browser and extension path.
|
|
|
|
for (const el of document.querySelectorAll('*')) {
|
|
// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
|
|
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
|
// Skip browser extension elements (Claude, etc.). Use getAttribute when
|
|
// `el.id` is not a string: a <form> with a named control like
|
|
// <input name="id"> shadows the builtin `id` getter and returns the
|
|
// element, whose `.startsWith` throws (issue #407).
|
|
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
|
|
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
|
// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
|
|
// These are inspector chrome, not part of the user's design.
|
|
if (el.closest('[id^="impeccable-live-"]')) continue;
|
|
// Skip html/body -- page-level findings go in the banner, not a full-page overlay
|
|
if (el === document.body || el === document.documentElement) continue;
|
|
|
|
const findings = [
|
|
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
|
|
...checkElementDesignSystemDOM(el, designSystem, designSeen),
|
|
].filter(f => _ruleOk(f.type));
|
|
|
|
addBrowserFindings(groupMap, el, findings);
|
|
|
|
// Hero eyebrow: the offending element is the eyebrow above the heading,
|
|
// not the heading itself — highlight the previous sibling instead.
|
|
const eyebrowFindings = checkElementHeroEyebrowDOM(el)
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (eyebrowFindings.length > 0 && el.previousElementSibling) {
|
|
addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings);
|
|
}
|
|
}
|
|
|
|
const pageLevelFindings = [];
|
|
|
|
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
|
|
.filter(f => _ruleOk(f.type));
|
|
if (designSourceFindings.length > 0) {
|
|
pageLevelFindings.push(...designSourceFindings);
|
|
addBrowserFindings(groupMap, document.body, designSourceFindings);
|
|
}
|
|
|
|
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
|
|
if (typoFindings.length > 0) {
|
|
pageLevelFindings.push(...typoFindings);
|
|
addBrowserFindings(groupMap, document.body, typoFindings);
|
|
}
|
|
|
|
const sectionKickerFindings = checkKickerAboveHeadingDOM()
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (sectionKickerFindings.length > 0) {
|
|
pageLevelFindings.push(...sectionKickerFindings);
|
|
addBrowserFindings(groupMap, document.body, sectionKickerFindings);
|
|
}
|
|
|
|
const numberedLabelFindings = checkNumberedSectionLabelsDOM()
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (numberedLabelFindings.length > 0) {
|
|
pageLevelFindings.push(...numberedLabelFindings);
|
|
addBrowserFindings(groupMap, document.body, numberedLabelFindings);
|
|
}
|
|
|
|
const repeatedTextFindings = checkRepeatedContainerTextDOM()
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (repeatedTextFindings.length > 0) {
|
|
pageLevelFindings.push(...repeatedTextFindings);
|
|
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
|
}
|
|
|
|
// Em-dash overuse (advisory): browser parity with the static/regex path.
|
|
// Reads rendered body text so it catches dashes written as HTML entities.
|
|
// serializeFindings stamps the advisory flag from the registry.
|
|
const emDashFindings = checkEmDashOveruseDOM()
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (emDashFindings.length > 0) {
|
|
pageLevelFindings.push(...emDashFindings);
|
|
addBrowserFindings(groupMap, document.body, emDashFindings);
|
|
}
|
|
|
|
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
|
for (const f of layoutFindings) {
|
|
const el = f.el || document.body;
|
|
addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
|
|
}
|
|
|
|
// Heading rhythm (browser-only: needs real layout for the gap math)
|
|
const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type));
|
|
for (const f of headingRhythmFindings) {
|
|
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
|
}
|
|
|
|
// Edge-flush cards in horizontal scrollers (browser-only: needs real
|
|
// layout for the scroller clip box vs card rect math)
|
|
const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
|
|
for (const f of edgeFlushFindings) {
|
|
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
|
}
|
|
|
|
// Text occlusion / element overlap (browser-only: needs real layout +
|
|
// elementFromPoint to confirm what actually paints on top)
|
|
const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type));
|
|
for (const f of occlusionFindings) {
|
|
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
|
}
|
|
|
|
// First-viewport column overflow — the stretched-hero signature
|
|
// (browser-only: needs real layout for the content-extent math)
|
|
const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type));
|
|
for (const f of colOverflowFindings) {
|
|
addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
|
|
}
|
|
|
|
// Page-level quality checks (headings, etc.)
|
|
const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
|
|
if (qualityFindings.length > 0) {
|
|
pageLevelFindings.push(...qualityFindings);
|
|
addBrowserFindings(groupMap, document.body, qualityFindings);
|
|
}
|
|
|
|
const creamFindings = checkCreamPalette(document)
|
|
.map(f => ({ type: f.id, detail: f.snippet }))
|
|
.filter(f => _ruleOk(f.type));
|
|
if (creamFindings.length > 0) {
|
|
pageLevelFindings.push(...creamFindings);
|
|
addBrowserFindings(groupMap, document.body, creamFindings);
|
|
}
|
|
|
|
// Regex-on-HTML checks (shared with Node)
|
|
// Clone the document and strip impeccable-live overlay nodes before the
|
|
// regex scan, so the inspector's own inline styles (transitions on top/
|
|
// left/width/height, etc.) don't register as page anti-patterns.
|
|
const docClone = document.documentElement.cloneNode(true);
|
|
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
|
node.remove();
|
|
}
|
|
// Regex findings that name a live selector resolve against the real DOM:
|
|
// pseudo-element/class segments are stripped (the host element is the
|
|
// anchor), a selector that matches nothing on this page drops the finding
|
|
// (the CSS ships here, but the pattern never renders — the live DOM is
|
|
// ground truth in the browser), and a match under a data-impeccable-ignore
|
|
// ancestor is waived. Selector-less findings stay page-level.
|
|
const html = docClone.outerHTML;
|
|
const corpora = buildHtmlPatternCorpora(html);
|
|
const linkedCss = linkedStylesheetText();
|
|
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
|
|
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
|
|
if (!f.selector) return true;
|
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
|
if (!matches) return false;
|
|
if (matches.length === 0) return false;
|
|
return matches.some(el => !scopedIgnoreActive(el, f.id));
|
|
});
|
|
if (scopedHtmlFindings.length > 0) {
|
|
const mapped = scopedHtmlFindings.map(f => {
|
|
const item = { type: f.id, detail: f.snippet };
|
|
if (f.severity) {
|
|
item.severity = f.severity;
|
|
} else if (f.id === 'pulsing-dot' && f.selector) {
|
|
// The string scan promotes header/nav dots on its own; with a live
|
|
// layout also promote dots resting in the first ~900px of the page
|
|
// (the hero region), which the source scan cannot measure.
|
|
try {
|
|
const dotEl = document.querySelector(f.selector);
|
|
if (dotEl) {
|
|
const rect = dotEl.getBoundingClientRect();
|
|
const pageTop = rect.top + (window.scrollY || 0);
|
|
if (pageTop <= 900) item.severity = 'error';
|
|
}
|
|
} catch { /* unresolvable selector: keep registry severity */ }
|
|
}
|
|
return item;
|
|
}).filter(f => _ruleOk(f.type));
|
|
pageLevelFindings.push(...mapped);
|
|
addBrowserFindings(groupMap, document.body, mapped);
|
|
}
|
|
|
|
// Value-level suppression (issue #639). `disabledRules` above handles
|
|
// whole rules; this applies the config's remaining ignoreValues entries,
|
|
// which the CLI filters through isIgnoredFindingValue in
|
|
// cli/lib/impeccable-config.mjs, so a project waiver like
|
|
// overused-font = "geist mono" reaches the overlay and extension too.
|
|
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
|
|
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
|
|
const _disabledValues = EXTENSION_MODE
|
|
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
|
|
.filter(e => e && typeof e === 'object' && e.rule && e.value)
|
|
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
|
|
: [];
|
|
if (_disabledValues.length > 0) {
|
|
// The six rules whose findings carry a matchable value; keep in step
|
|
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
|
|
// Everything else is suppressed by rule or by file scope, both already
|
|
// resolved into disabledRules before the scan message was sent.
|
|
const _directValueRules = new Set([
|
|
'overused-font',
|
|
'bounce-easing',
|
|
'design-system-font',
|
|
'design-system-color',
|
|
'design-system-radius',
|
|
'design-system-font-size',
|
|
]);
|
|
// The design-system checks set `ignoreValue` on their findings; the
|
|
// detail fallbacks catch overused-font, whose value lives in its
|
|
// sentence. One CLI matcher is not mirrored here: the motion extractor
|
|
// (a value-scoped bounce-easing waiver only matches when the finding
|
|
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
|
|
// also omitted on purpose: browser findings for these rules always
|
|
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
|
|
// font-family sentence, so it is unreachable here.
|
|
const _findingValue = (f) => {
|
|
if (!f || !_directValueRules.has(f.type || f.id)) return '';
|
|
const direct = f.ignoreValue || f.value;
|
|
if (direct) return _normValue(direct);
|
|
// The CLI routes bounce-easing through extractMotionIgnoreValue and
|
|
// never the font regexes; without a direct ignoreValue there is no
|
|
// value to match, so do not invent one from unrelated CSS text.
|
|
if ((f.type || f.id) === 'bounce-easing') return '';
|
|
for (const text of [f.detail, f.snippet]) {
|
|
if (typeof text !== 'string' || !text) continue;
|
|
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
if (primary) return _normValue(primary[1]);
|
|
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
|
|
if (google) return _normValue(google[1]);
|
|
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
if (family) return _normValue(family[1]);
|
|
}
|
|
return '';
|
|
};
|
|
// design-system-color compares by color value, not by spelling: the
|
|
// browser reports computed rgb(...) strings while waivers are usually
|
|
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
|
|
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
|
|
// hsl stays CLI-only.
|
|
const _colorKey = (value) => {
|
|
const text = String(value || '').trim().toLowerCase();
|
|
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
|
if (hex) {
|
|
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
|
|
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
|
|
return `${r},${g},${b},${a}`;
|
|
}
|
|
const rgb = text.match(/^rgba?\((.*)\)$/);
|
|
if (!rgb) return '';
|
|
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
|
|
let parts;
|
|
if (body.includes(',')) {
|
|
parts = body.split(',').map(p => p.trim()).filter(Boolean);
|
|
const last = parts[parts.length - 1];
|
|
if (last && last.includes('/')) {
|
|
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
|
|
}
|
|
} else {
|
|
parts = body.split(/\s+/).filter(p => p && p !== '/');
|
|
}
|
|
if (parts.length < 3 || parts.length > 4) return '';
|
|
const channel = (raw, isAlpha) => {
|
|
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
|
|
if (!m) return null;
|
|
let v = parseFloat(m[1]);
|
|
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
|
|
const max = isAlpha ? 1 : 255;
|
|
if (!Number.isFinite(v) || v < 0 || v > max) return null;
|
|
return isAlpha ? v : Math.round(v);
|
|
};
|
|
const r = channel(parts[0], false);
|
|
const g = channel(parts[1], false);
|
|
const b = channel(parts[2], false);
|
|
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
|
|
if ([r, g, b, a].some(v => v === null)) return '';
|
|
return `${r},${g},${b},${Math.round(a * 255)}`;
|
|
};
|
|
const _valueIgnored = (f) => {
|
|
const value = _findingValue(f);
|
|
if (!value) return false;
|
|
const rule = f.type || f.id;
|
|
return _disabledValues.some(e => e.rule === rule && (e.value === value
|
|
|| (rule === 'design-system-color'
|
|
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
|
|
};
|
|
for (const [el, list] of [...groupMap.entries()]) {
|
|
const kept = list.filter(f => !_valueIgnored(f));
|
|
if (kept.length > 0) groupMap.set(el, kept);
|
|
else groupMap.delete(el);
|
|
}
|
|
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
|
|
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
return {
|
|
groupMap,
|
|
allFindings: browserFindingsFromMap(groupMap),
|
|
pageLevelFindings,
|
|
};
|
|
}
|
|
|
|
// Visual contrast has three modes. Explicit true runs the full sampled
|
|
// pass; explicit false disables it entirely (the deterministic-only mode
|
|
// the test suites use). Unset — the default overlay run — samples ONLY
|
|
// image-backed text: the one class the analytic walk deliberately skips,
|
|
// because a url() layer's pixels are unknowable without looking. In-page
|
|
// sampling draws the source image alone to a canvas (glyph ink never
|
|
// pollutes it), and a cross-origin image without CORS reports unresolved
|
|
// instead of guessing.
|
|
function visualContrastMode(options = {}) {
|
|
const explicit = typeof options.visualContrast === 'boolean'
|
|
? options.visualContrast
|
|
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
|
? window.__IMPECCABLE_CONFIG__.visualContrast
|
|
: null;
|
|
if (explicit === true) return 'full';
|
|
if (explicit === false) return false;
|
|
return 'image-only';
|
|
}
|
|
|
|
function shouldRunVisualContrast(options = {}) {
|
|
return visualContrastMode(options) !== false;
|
|
}
|
|
|
|
function visualContrastOptions(options = {}) {
|
|
const config = window.__IMPECCABLE_CONFIG__ || {};
|
|
const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'
|
|
? options.scrollOffscreen
|
|
: typeof options.visualContrastScrollOffscreen === 'boolean'
|
|
? options.visualContrastScrollOffscreen
|
|
: typeof config.visualContrastScrollOffscreen === 'boolean'
|
|
? config.visualContrastScrollOffscreen
|
|
: false;
|
|
return {
|
|
...options,
|
|
maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
|
|
? options.visualContrastMaxCandidates
|
|
: Number.isFinite(options.maxCandidates)
|
|
? options.maxCandidates
|
|
: Number.isFinite(config.visualContrastMaxCandidates)
|
|
? config.visualContrastMaxCandidates
|
|
: undefined,
|
|
scrollOffscreen,
|
|
};
|
|
}
|
|
|
|
let lastVisualContrastAnalyses = [];
|
|
let lazyVisualContrastObserver = null;
|
|
let lazyVisualContrastPending = new WeakMap();
|
|
const lazyVisualContrastResolving = new WeakSet();
|
|
let scanGeneration = 0;
|
|
|
|
function rememberVisualContrastAnalysis(result) {
|
|
if (!result?.selector) {
|
|
lastVisualContrastAnalyses.push(result);
|
|
return;
|
|
}
|
|
const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
|
|
if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
|
|
else lastVisualContrastAnalyses.push(result);
|
|
}
|
|
|
|
function disconnectLazyVisualContrastObserver() {
|
|
if (lazyVisualContrastObserver) {
|
|
lazyVisualContrastObserver.disconnect();
|
|
lazyVisualContrastObserver = null;
|
|
}
|
|
lazyVisualContrastPending = new WeakMap();
|
|
}
|
|
|
|
function addVisualContrastResult(groupMap, result, options = {}) {
|
|
if (result.status !== 'fail' || !result.finding || !result.selector) return false;
|
|
let el = null;
|
|
try {
|
|
el = document.querySelector(result.selector);
|
|
} catch {
|
|
el = null;
|
|
}
|
|
if (!el) return false;
|
|
const findingType = result.finding.type || result.finding.id || 'low-contrast';
|
|
const existing = groupMap.get(el) || [];
|
|
if (existing.some(f => (f.type || f.id) === findingType)) return false;
|
|
addBrowserFindings(groupMap, el, [{
|
|
type: findingType,
|
|
detail: result.finding.detail || result.finding.snippet,
|
|
}]);
|
|
if (options.decorate && el !== document.body && el !== document.documentElement) {
|
|
highlight(el, groupMap.get(el) || []);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function scanResultMeta(options = {}) {
|
|
const scanId = options.scanId;
|
|
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
|
return { scanId: String(scanId) };
|
|
}
|
|
|
|
function postSerializedFindings(groupMap, options = {}) {
|
|
if (!EXTENSION_MODE) return;
|
|
const allFindings = browserFindingsFromMap(groupMap);
|
|
window.postMessage({
|
|
source: 'impeccable-results',
|
|
findings: serializeFindings(allFindings),
|
|
count: allFindings.length,
|
|
...scanResultMeta(options),
|
|
}, '*');
|
|
}
|
|
|
|
function postExtensionError(err) {
|
|
if (!EXTENSION_MODE) return;
|
|
window.postMessage({
|
|
source: 'impeccable-error',
|
|
message: err?.message || String(err),
|
|
}, '*');
|
|
}
|
|
|
|
function reportVisualContrastError(err, detail = {}) {
|
|
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {
|
|
detail: {
|
|
...detail,
|
|
message: err?.message || String(err),
|
|
},
|
|
}));
|
|
if (EXTENSION_MODE) {
|
|
postExtensionError(err);
|
|
} else {
|
|
console.warn('[impeccable] visual contrast scan failed', err);
|
|
}
|
|
}
|
|
|
|
function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
|
|
disconnectLazyVisualContrastObserver();
|
|
if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
|
|
if (typeof IntersectionObserver === 'undefined') return;
|
|
const unresolved = (analyses || []).filter(result =>
|
|
result?.status === 'unresolved' &&
|
|
result.reason === 'text outside viewport' &&
|
|
result.selector
|
|
);
|
|
if (unresolved.length === 0) return;
|
|
const generation = runtime.generation || scanGeneration;
|
|
|
|
lazyVisualContrastObserver = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting) continue;
|
|
const el = entry.target;
|
|
const candidate = lazyVisualContrastPending.get(el);
|
|
if (!candidate || lazyVisualContrastResolving.has(el)) continue;
|
|
lazyVisualContrastObserver?.unobserve(el);
|
|
lazyVisualContrastPending.delete(el);
|
|
lazyVisualContrastResolving.add(el);
|
|
waitForVisualPaint()
|
|
.then(() => analyzeVisualContrastCandidate(candidate))
|
|
.then(result => {
|
|
if (generation !== scanGeneration) return;
|
|
rememberVisualContrastAnalysis(result);
|
|
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
|
if (added) {
|
|
postSerializedFindings(groupMap, options);
|
|
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
|
detail: {
|
|
selector: result.selector,
|
|
status: result.status,
|
|
finding: result.finding || null,
|
|
},
|
|
}));
|
|
}
|
|
})
|
|
.catch(err => {
|
|
reportVisualContrastError(err, { selector: candidate.selector });
|
|
})
|
|
.finally(() => {
|
|
lazyVisualContrastResolving.delete(el);
|
|
});
|
|
}
|
|
}, { threshold: 0.5 });
|
|
|
|
for (const candidate of unresolved) {
|
|
let el = null;
|
|
try {
|
|
el = document.querySelector(candidate.selector);
|
|
} catch {
|
|
el = null;
|
|
}
|
|
if (!el) continue;
|
|
lazyVisualContrastPending.set(el, candidate);
|
|
lazyVisualContrastObserver.observe(el);
|
|
}
|
|
}
|
|
|
|
async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
|
|
if (!shouldRunVisualContrast(options)) {
|
|
lastVisualContrastAnalyses = [];
|
|
disconnectLazyVisualContrastObserver();
|
|
return [];
|
|
}
|
|
const resolvedOptions = visualContrastOptions(options);
|
|
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
|
const analyses = await analyzeVisualContrast(resolvedOptions);
|
|
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
|
lastVisualContrastAnalyses = analyses;
|
|
for (const result of analyses) {
|
|
addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
|
|
}
|
|
if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
|
|
return analyses;
|
|
}
|
|
|
|
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
|
|
const collected = collectBrowserFindings();
|
|
// The visual pass walks the DOM on its own; on a skipScan page it would
|
|
// repopulate the emptied scan, so it is skipped with everything else.
|
|
if (skipScanActive()) {
|
|
lastVisualContrastAnalyses = [];
|
|
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
|
|
}
|
|
await addVisualContrastFindings(collected.groupMap, options, runtime);
|
|
return {
|
|
...collected,
|
|
allFindings: browserFindingsFromMap(collected.groupMap),
|
|
visualContrastAnalyses: lastVisualContrastAnalyses,
|
|
};
|
|
}
|
|
|
|
function clearOverlays() {
|
|
scanGeneration += 1;
|
|
disconnectLazyVisualContrastObserver();
|
|
for (const o of [...overlays]) detachOverlay(o);
|
|
overlays.length = 0;
|
|
visibilityObserver.disconnect();
|
|
overlayIndex = 0;
|
|
}
|
|
|
|
function renderBrowserFindings(collected, options = {}) {
|
|
const { allFindings, pageLevelFindings } = collected;
|
|
|
|
for (const { el, findings } of allFindings) {
|
|
if (el === document.body || el === document.documentElement) continue;
|
|
highlight(el, findings);
|
|
}
|
|
|
|
if (pageLevelFindings.length > 0) {
|
|
showPageBanner(pageLevelFindings);
|
|
}
|
|
|
|
if (!EXTENSION_MODE) printSummary(allFindings);
|
|
|
|
// In extension mode, post serialized results for the DevTools panel
|
|
if (EXTENSION_MODE) {
|
|
window.postMessage({
|
|
source: 'impeccable-results',
|
|
findings: serializeFindings(allFindings),
|
|
count: allFindings.length,
|
|
...scanResultMeta(options),
|
|
}, '*');
|
|
}
|
|
|
|
// After this scan completes, all subsequent reveals are instant (no stagger, no animation)
|
|
setTimeout(() => { firstScanDone = true; }, 1000);
|
|
|
|
return allFindings;
|
|
}
|
|
|
|
let firstScanDone = false;
|
|
const scan = function(options = {}) {
|
|
clearOverlays();
|
|
const generation = scanGeneration;
|
|
const collected = collectBrowserFindings();
|
|
const allFindings = renderBrowserFindings(collected, options);
|
|
if (!skipScanActive() && shouldRunVisualContrast(options)) {
|
|
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
|
.then(() => {
|
|
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
|
})
|
|
.catch(err => {
|
|
reportVisualContrastError(err);
|
|
});
|
|
}
|
|
return allFindings;
|
|
};
|
|
|
|
const scanAsync = async function(options = {}) {
|
|
clearOverlays();
|
|
const generation = scanGeneration;
|
|
if (shouldRunVisualContrast(options)) {
|
|
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
|
if (generation !== scanGeneration) return [];
|
|
return renderBrowserFindings(collected, options);
|
|
}
|
|
lastVisualContrastAnalyses = [];
|
|
return renderBrowserFindings(collectBrowserFindings(), options);
|
|
};
|
|
|
|
const detect = function(options = {}) {
|
|
lastVisualContrastAnalyses = [];
|
|
const { allFindings } = collectBrowserFindings();
|
|
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
|
};
|
|
|
|
const detectAsync = async function(options = {}) {
|
|
if (shouldRunVisualContrast(options)) {
|
|
const { allFindings } = await collectBrowserFindingsAsync(options);
|
|
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
|
}
|
|
lastVisualContrastAnalyses = [];
|
|
const { allFindings } = collectBrowserFindings();
|
|
return options.serialize === false ? allFindings : serializeFindings(allFindings);
|
|
};
|
|
|
|
if (EXTENSION_MODE) {
|
|
// Extension mode: listen for commands, don't auto-scan
|
|
window.addEventListener('message', (e) => {
|
|
if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
|
|
if (e.data.action === 'scan') {
|
|
if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
|
|
try {
|
|
scan(e.data.config || {});
|
|
} catch (err) {
|
|
postExtensionError(err);
|
|
}
|
|
}
|
|
if (e.data.action === 'toggle-overlays') {
|
|
const visible = !document.body.classList.contains('impeccable-hidden');
|
|
document.body.classList.toggle('impeccable-hidden', visible);
|
|
window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
|
|
}
|
|
if (e.data.action === 'remove') {
|
|
clearOverlays();
|
|
styleEl.remove();
|
|
if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
|
|
document.body.classList.remove('impeccable-hidden');
|
|
}
|
|
if (e.data.action === 'highlight') {
|
|
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' }, '*');
|
|
} else {
|
|
if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {
|
|
const runAutoScan = () => {
|
|
try {
|
|
scan();
|
|
} catch (err) {
|
|
console.warn('[impeccable] scan failed', err);
|
|
}
|
|
};
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
|
|
} else {
|
|
setTimeout(runAutoScan, 100);
|
|
}
|
|
}
|
|
}
|
|
|
|
window.impeccableDetect = detect;
|
|
window.impeccableDetectAsync = detectAsync;
|
|
window.impeccableScan = scan;
|
|
window.impeccableScanAsync = scanAsync;
|
|
// Raw measurement for the URL engine's content-hidden-at-rest pass: it
|
|
// drives a reveal sweep from Node and thresholds the result itself.
|
|
window.impeccableMeasureHiddenText = measureHiddenTextDOM;
|
|
window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
|
|
window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
|
|
window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
|
|
}
|