Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/browser/injected/index.mjs
1const IS_BROWSER = typeof window !== 'undefined';23// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────45if (IS_BROWSER) {6// Detect extension mode via the script tag's data attribute or the document element fallback.7// currentScript is reliable for synchronously-executing scripts (which our IIFE is).8const _myScript = document.currentScript;9const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')10|| document.documentElement.dataset.impeccableExtension === 'true';1112// Kinpaku gold — pinned to the site's brand token (see13// site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with14// the picker's C.brand in skill/scripts/live-browser.js and the kit's15// picker section in site/styles/kinpaku-kit.css.16//17// One color across both light and dark host pages. The outline is a18// 2px gesture pointing at an element + a labeled tag — it's a marker,19// not body text, so it doesn't need WCAG AA against the page. The20// label text inside the gold tag is dark (LABEL_INK) which has ~16:121// against the leaf gold, so reading the rule name is solid in both22// modes. Hover deepens the gold (preserves chroma — never drops it,23// dropping chroma washes the gold into a sand/olive tone).24const BRAND_COLOR = 'oklch(84% 0.19 80.46)';25const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';26const LABEL_INK = 'oklch(4% 0.004 95)';27const LABEL_BG = BRAND_COLOR;28const OUTLINE_COLOR = BRAND_COLOR;2930// Inject hover styles via CSS (more reliable than JS event listeners)31const styleEl = document.createElement('style');32styleEl.textContent = `33@keyframes impeccable-reveal {34from { opacity: 0; }35to { opacity: 1; }36}37.impeccable-overlay:not(.impeccable-banner) {38pointer-events: none;39outline: 2px solid ${OUTLINE_COLOR};40border-radius: 4px;41transition: outline-color 0.15s ease;42animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;43animation-play-state: paused;44border-top-left-radius: 0;45}46.impeccable-overlay.impeccable-visible {47animation-play-state: running;48}49.impeccable-overlay.impeccable-hover {50outline-color: ${BRAND_COLOR_HOVER};51z-index: 100001 !important;52}53.impeccable-overlay.impeccable-hover .impeccable-label {54background: ${BRAND_COLOR_HOVER};55}56.impeccable-overlay.impeccable-spotlight {57z-index: 100002 !important;58}59.impeccable-overlay.impeccable-spotlight-dimmed {60opacity: 0.15 !important;61animation: none !important;62filter: blur(3px);63}64.impeccable-spotlight-backdrop {65position: fixed;66top: 0; left: 0; right: 0; bottom: 0;67backdrop-filter: blur(3px) brightness(0.6);68-webkit-backdrop-filter: blur(3px) brightness(0.6);69pointer-events: none;70z-index: 99998;71opacity: 0;72outline: none !important;73animation: none !important;74}75.impeccable-spotlight-backdrop.impeccable-visible {76opacity: 1;77}78.impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {79display: none !important;80}81`;82(document.head || document.documentElement).appendChild(styleEl);8384// Spotlight backdrop element (created lazily on first use)85let spotlightBackdrop = null;86let spotlightTarget = null;8788function getSpotlightBackdrop() {89if (!spotlightBackdrop) {90spotlightBackdrop = document.createElement('div');91spotlightBackdrop.className = 'impeccable-spotlight-backdrop';92document.body.appendChild(spotlightBackdrop);93}94return spotlightBackdrop;95}9697function updateSpotlightClipPath() {98if (!spotlightBackdrop || !spotlightTarget) return;99const r = spotlightTarget.getBoundingClientRect();100// Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)101const inset = 4;102const radius = 6; // outline border-radius (4) + outline width (2)103const x1 = r.left - inset;104const y1 = r.top - inset;105const x2 = r.right + inset;106const y2 = r.bottom + inset;107const vw = window.innerWidth;108const vh = window.innerHeight;109// Outer rect + rounded inner rect (evenodd creates a hole)110const 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`;111spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;112}113114function showSpotlight(target) {115if (!target || !target.getBoundingClientRect) return;116// Respect the spotlightBlur setting: if disabled, don't show the backdrop117if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {118spotlightTarget = target;119return;120}121spotlightTarget = target;122const bd = getSpotlightBackdrop();123updateSpotlightClipPath();124bd.classList.add('impeccable-visible');125}126127function hideSpotlight() {128spotlightTarget = null;129if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');130}131132function isInViewport(el) {133const r = el.getBoundingClientRect();134return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;135}136137// Reposition spotlight on scroll/resize138window.addEventListener('scroll', () => {139if (spotlightTarget) updateSpotlightClipPath();140}, { passive: true });141window.addEventListener('resize', () => {142if (spotlightTarget) updateSpotlightClipPath();143});144145const overlays = [];146const TYPE_LABELS = {};147const RULE_CATEGORY = {};148for (const ap of ANTIPATTERNS) {149TYPE_LABELS[ap.id] = ap.name.toLowerCase();150RULE_CATEGORY[ap.id] = ap.category || 'quality';151}152153function isInFixedContext(el) {154let p = el;155while (p && p !== document.body) {156if (getComputedStyle(p).position === 'fixed') return true;157p = p.parentElement;158}159return false;160}161162function positionOverlay(overlay) {163const el = overlay._targetEl;164if (!el) return;165const rect = el.getBoundingClientRect();166if (overlay._isFixed) {167// Viewport-relative coords for fixed targets168overlay.style.top = `${rect.top - 2}px`;169overlay.style.left = `${rect.left - 2}px`;170} else {171// Document-relative coords for normal targets172overlay.style.top = `${rect.top + scrollY - 2}px`;173overlay.style.left = `${rect.left + scrollX - 2}px`;174}175overlay.style.width = `${rect.width + 4}px`;176overlay.style.height = `${rect.height + 4}px`;177}178179function repositionOverlays() {180for (const o of overlays) {181if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;182// Skip overlays whose target is currently hidden (display: none on the overlay)183if (o.style.display === 'none') continue;184positionOverlay(o);185}186}187188let resizeRAF;189const onResize = () => {190cancelAnimationFrame(resizeRAF);191resizeRAF = requestAnimationFrame(repositionOverlays);192};193window.addEventListener('resize', onResize);194// Reposition on scroll too -- catches sticky/parallax shifts195window.addEventListener('scroll', onResize, { passive: true });196// Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)197if (typeof ResizeObserver !== 'undefined') {198const bodyResizeObserver = new ResizeObserver(onResize);199bodyResizeObserver.observe(document.body);200}201202// Track target element visibility via IntersectionObserver.203// Uses a huge rootMargin so all *rendered* elements count as intersecting,204// while display:none / closed <details> / hidden modals etc. do not.205// This is event-driven -- no polling needed.206let overlayIndex = 0;207const visibilityObserver = new IntersectionObserver((entries) => {208for (const entry of entries) {209const overlay = entry.target._impeccableOverlay;210if (!overlay) continue;211if (entry.isIntersecting) {212overlay.style.display = '';213positionOverlay(overlay);214if (!overlay._revealed) {215overlay._revealed = true;216if (firstScanDone) {217// Subsequent reveals (re-scans, scroll-into-view): instant, no animation218overlay.style.animation = 'none';219} else {220// Initial scan: staggered cascade reveal221overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;222}223requestAnimationFrame(() => {224overlay.classList.add('impeccable-visible');225if (overlay._checkLabel) overlay._checkLabel();226});227}228} else {229overlay.style.display = 'none';230}231}232}, { rootMargin: '99999px' });233234function detachOverlay(overlay) {235if (!overlay) return;236if (typeof overlay._cleanup === 'function') {237try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }238}239if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {240visibilityObserver.unobserve(overlay._targetEl);241delete overlay._targetEl._impeccableOverlay;242}243const idx = overlays.indexOf(overlay);244if (idx >= 0) overlays.splice(idx, 1);245overlay.remove();246}247248// Reposition overlays after CSS transitions end (e.g. reveal animations).249// Listens at document level so it catches transitions on ancestor elements250// (the transform may be on a parent, not the flagged element itself).251document.addEventListener('transitionend', (e) => {252if (e.propertyName !== 'transform') return;253for (const o of overlays) {254if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;255if (e.target === o._targetEl || e.target.contains(o._targetEl)) {256positionOverlay(o);257}258}259});260261const highlight = function(el, findings) {262if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);263const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');264265const fixed = isInFixedContext(el);266const rect = el.getBoundingClientRect();267const outline = document.createElement('div');268outline.className = 'impeccable-overlay';269outline._targetEl = el;270outline._isFixed = fixed;271Object.assign(outline.style, {272position: fixed ? 'fixed' : 'absolute',273top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,274left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,275width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,276zIndex: '99999', boxSizing: 'border-box',277});278279// Build per-finding label entries: ✦ prefix for slop280const entries = findings.map(f => {281const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;282const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';283return { name: prefix + name, detail: f.detail || f.snippet };284});285const allText = entries.map(e => e.name).join(', ');286287const label = document.createElement('div');288label.className = 'impeccable-label';289Object.assign(label.style, {290position: 'absolute', bottom: '100%', left: '-2px',291display: 'flex', alignItems: 'center',292whiteSpace: 'nowrap',293fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',294color: LABEL_INK, lineHeight: '14px',295background: LABEL_BG,296fontFamily: 'system-ui, sans-serif',297borderRadius: '4px 4px 0 0',298});299300const textSpan = document.createElement('span');301textSpan.style.padding = '3px 8px';302textSpan.textContent = allText;303label.appendChild(textSpan);304305// State for cycling mode306let cycleMode = false;307let cycleIndex = 0;308let isHovered = false;309let prevBtn, nextBtn;310311function updateCycleText() {312const e = entries[cycleIndex];313textSpan.textContent = isHovered ? e.detail : e.name;314}315316function enableCycleMode() {317if (cycleMode || entries.length < 2) return;318cycleMode = true;319320const btnStyle = {321background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',322fontSize: '11px', cursor: 'pointer', padding: '3px 4px',323fontFamily: 'system-ui, sans-serif', lineHeight: '14px',324pointerEvents: 'auto',325};326327const navGroup = document.createElement('span');328Object.assign(navGroup.style, {329display: 'inline-flex', alignItems: 'center', flexShrink: '0',330});331332prevBtn = document.createElement('button');333prevBtn.textContent = '\u2039';334Object.assign(prevBtn.style, btnStyle);335prevBtn.style.paddingLeft = '6px';336prevBtn.addEventListener('click', (e) => {337e.stopPropagation();338cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;339updateCycleText();340});341342nextBtn = document.createElement('button');343nextBtn.textContent = '\u203A';344Object.assign(nextBtn.style, btnStyle);345nextBtn.style.paddingRight = '2px';346nextBtn.addEventListener('click', (e) => {347e.stopPropagation();348cycleIndex = (cycleIndex + 1) % entries.length;349updateCycleText();350});351352navGroup.appendChild(prevBtn);353navGroup.appendChild(nextBtn);354label.insertBefore(navGroup, textSpan);355textSpan.style.padding = '3px 8px 3px 4px';356updateCycleText();357}358359outline.appendChild(label);360361// Start hidden; the IntersectionObserver will show it once the target is rendered362outline.style.display = 'none';363outline._staggerIndex = overlayIndex++;364el._impeccableOverlay = outline;365visibilityObserver.observe(el);366367// After first paint, check label width vs outline368outline._checkLabel = () => {369if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {370enableCycleMode();371}372};373374// Hover: show detail text, darken375const onMouseEnter = () => {376isHovered = true;377outline.classList.add('impeccable-hover');378outline.style.outlineColor = BRAND_COLOR_HOVER;379label.style.background = BRAND_COLOR_HOVER;380if (cycleMode) {381updateCycleText();382} else {383textSpan.textContent = entries.map(e => e.detail).join(' | ');384}385};386const onMouseLeave = () => {387isHovered = false;388outline.classList.remove('impeccable-hover');389outline.style.outlineColor = '';390label.style.background = LABEL_BG;391if (cycleMode) {392updateCycleText();393} else {394textSpan.textContent = allText;395}396};397el.addEventListener('mouseenter', onMouseEnter);398el.addEventListener('mouseleave', onMouseLeave);399outline._cleanup = () => {400el.removeEventListener('mouseenter', onMouseEnter);401el.removeEventListener('mouseleave', onMouseLeave);402};403404document.body.appendChild(outline);405overlays.push(outline);406};407408const showPageBanner = function(findings) {409if (!findings.length) return;410const banner = document.createElement('div');411banner.className = 'impeccable-overlay impeccable-banner';412Object.assign(banner.style, {413position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',414background: LABEL_BG, color: LABEL_INK,415fontFamily: 'system-ui, sans-serif', fontSize: '13px',416display: 'flex', alignItems: 'center', pointerEvents: 'auto',417height: '36px', overflow: 'hidden', maxWidth: '100vw',418transform: 'translateY(-100%)',419transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',420});421requestAnimationFrame(() => requestAnimationFrame(() => {422banner.style.transform = 'translateY(0)';423}));424425// Scrollable findings area426const scrollArea = document.createElement('div');427Object.assign(scrollArea.style, {428flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',429display: 'flex', gap: '8px', alignItems: 'center',430padding: '0 12px', scrollSnapType: 'x mandatory',431scrollbarWidth: 'none',432});433for (const f of findings) {434const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';435const tag = document.createElement('span');436tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;437Object.assign(tag.style, {438background: 'rgba(255,255,255,0.15)', padding: '2px 8px',439borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',440whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',441});442scrollArea.appendChild(tag);443}444banner.appendChild(scrollArea);445446// Controls area (only in standalone mode, not extension)447if (!EXTENSION_MODE) {448const controls = document.createElement('div');449Object.assign(controls.style, {450display: 'flex', alignItems: 'center', gap: '2px',451padding: '0 8px', flexShrink: '0',452});453454// Toggle visibility button455const toggle = document.createElement('button');456toggle.textContent = '\u25C9'; // circle with dot (visible state)457toggle.title = 'Toggle overlay visibility';458Object.assign(toggle.style, {459background: 'none', border: 'none',460color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',461opacity: '0.85', transition: 'opacity 0.15s',462});463let overlaysVisible = true;464toggle.addEventListener('click', () => {465overlaysVisible = !overlaysVisible;466document.body.classList.toggle('impeccable-hidden', !overlaysVisible);467toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle468toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';469});470controls.appendChild(toggle);471472// Close button473const close = document.createElement('button');474close.textContent = '\u00d7';475close.title = 'Dismiss banner';476Object.assign(close.style, {477background: 'none', border: 'none',478color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',479});480close.addEventListener('click', () => banner.remove());481controls.appendChild(close);482483banner.appendChild(controls);484}485document.body.appendChild(banner);486overlays.push(banner);487};488489// Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".490// These change between builds and produce brittle, ugly selectors.491function isLikelyHashedClass(c) {492if (!c) return true;493if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;494if (/^_[\w-]{5,}$/.test(c)) return true;495if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;496return false;497}498499function buildSelectorSegment(el) {500const tag = el.tagName.toLowerCase();501let sel = tag;502503if (el.classList && el.classList.length > 0) {504const classes = [...el.classList]505.filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))506.slice(0, 2);507if (classes.length > 0) {508sel += '.' + classes.map(c => CSS.escape(c)).join('.');509}510}511512// Disambiguate among siblings only if the parent has multiple matches513const parent = el.parentElement;514if (parent) {515try {516const matching = parent.querySelectorAll(':scope > ' + sel);517if (matching.length > 1) {518const sameType = [...parent.children].filter(c => c.tagName === el.tagName);519const idx = sameType.indexOf(el) + 1;520sel += `:nth-of-type(${idx})`;521}522} catch {523const idx = [...parent.children].indexOf(el) + 1;524sel = `${tag}:nth-child(${idx})`;525}526}527return sel;528}529530function generateSelector(el) {531if (el === document.body) return 'body';532if (el === document.documentElement) return 'html';533if (el.id) return '#' + CSS.escape(el.id);534535const parts = [];536let current = el;537let depth = 0;538const MAX_DEPTH = 10;539540while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {541parts.unshift(buildSelectorSegment(current));542543// Anchor on an ancestor's ID and stop walking up544if (current.id) {545parts[0] = '#' + CSS.escape(current.id);546break;547}548549// Stop as soon as the partial selector uniquely identifies the target550const trySelector = parts.join(' > ');551try {552const matches = document.querySelectorAll(trySelector);553if (matches.length === 1 && matches[0] === el) {554return trySelector;555}556} catch { /* invalid selector — keep walking */ }557558current = current.parentElement;559depth++;560}561562return parts.join(' > ');563}564565function getDirectText(el) {566return [...el.childNodes]567.filter(n => n.nodeType === 3)568.map(n => n.textContent || '')569.join('');570}571572function getDirectTextRect(el) {573const rects = [];574for (const node of el.childNodes) {575if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;576const range = document.createRange();577range.selectNodeContents(node);578for (const rect of range.getClientRects()) {579if (rect.width >= 1 && rect.height >= 1) rects.push(rect);580}581range.detach?.();582}583if (rects.length === 0) return null;584const left = Math.min(...rects.map(r => r.left));585const top = Math.min(...rects.map(r => r.top));586const right = Math.max(...rects.map(r => r.right));587const bottom = Math.max(...rects.map(r => r.bottom));588return {589left,590top,591right,592bottom,593width: right - left,594height: bottom - top,595x: left,596y: top,597};598}599600function collectVisualContrastReasons(el, style) {601const reasons = new Set();602const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';603const ownBgImage = style.backgroundImage || '';604if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {605reasons.add('background-clip text');606}607if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');608609let current = el;610while (current && current.nodeType === 1) {611const tag = current.tagName?.toLowerCase();612const currentStyle = getComputedStyle(current);613const bgImage = currentStyle.backgroundImage || '';614const isDocumentSurface = tag === 'body' || tag === 'html';615616if (!isDocumentSurface && bgImage && bgImage !== 'none') {617if (/url\s*\(/i.test(bgImage)) reasons.add('image background');618if (/gradient/i.test(bgImage)) reasons.add('gradient background');619}620if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');621if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');622if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');623if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');624625const solidBg = parseRgb(currentStyle.backgroundColor);626if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;627current = current.parentElement;628}629630const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();631if (sampleRect && document.elementsFromPoint) {632const points = [633[sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],634[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],635[sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],636];637for (const [x, y] of points) {638if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;639const stack = document.elementsFromPoint(x, y);640const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));641if (selfIndex < 0) continue;642for (const node of stack.slice(selfIndex + 1)) {643const nodeTag = node.tagName?.toLowerCase();644if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {645reasons.add(`${nodeTag} underlay`);646break;647}648}649}650}651652return [...reasons];653}654655function collectVisualContrastCandidates(options = {}) {656const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;657const candidates = [];658for (const el of document.querySelectorAll('*')) {659if (candidates.length >= maxCandidates) break;660if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;661if (el.closest('[id^="impeccable-live-"]')) continue;662if (el === document.body || el === document.documentElement) continue;663if (!isRenderedForBrowserRule(el)) continue;664665const tag = el.tagName.toLowerCase();666const style = getComputedStyle(el);667if (style.display === 'none' || style.visibility === 'hidden') continue;668const directText = getDirectText(el);669const hasDirectText = directText.trim().length > 0;670if (!hasDirectText || isEmojiOnlyText(directText)) continue;671672const bgColor = readOwnBackgroundColor(el, style);673const isStyledButton = (tag === 'a' || tag === 'button')674&& bgColor && bgColor.a > 0.5;675if (SAFE_TAGS.has(tag) && !isStyledButton) continue;676677const rect = getDirectTextRect(el) || el.getBoundingClientRect();678if (!rect || rect.width < 4 || rect.height < 4) continue;679680const reasons = collectVisualContrastReasons(el, style);681if (reasons.length === 0) continue;682683const textColor = parseRgb(style.color);684const fontSize = parseFloat(style.fontSize) || 16;685const fontWeight = parseInt(style.fontWeight) || 400;686const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);687const threshold = isLargeText ? 3.0 : 4.5;688const clip = {689x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),690y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),691width: Math.max(1, Math.ceil(rect.width + 4)),692height: Math.max(1, Math.ceil(rect.height + 4)),693};694695candidates.push({696selector: generateSelector(el),697tagName: tag,698text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),699threshold,700reasons,701clip,702textColor,703preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>704reason === 'opacity stack' ||705reason === 'blend mode' ||706reason === 'filter' ||707reason === 'backdrop filter' ||708reason === 'background-clip text'709),710backgroundClipText: reasons.includes('background-clip text'),711});712}713return candidates;714}715716const visualContrastImageCache = new Map();717const visualContrastRasterCache = new WeakMap();718719function clampByte(value) {720return Math.max(0, Math.min(255, Math.round(value)));721}722723function blendRgba(fg, bg) {724if (!fg) return bg || null;725if (!bg || fg.a == null || fg.a >= 0.999) {726return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };727}728const alpha = Math.max(0, Math.min(1, fg.a));729return {730r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),731g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),732b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),733a: 1,734};735}736737function pickWorstContrastColor(textColor, colors) {738const usable = (colors || []).filter(Boolean);739if (!usable.length) return null;740let worst = usable[0];741let worstRatio = contrastRatio(textColor, worst);742for (const color of usable.slice(1)) {743const ratio = contrastRatio(textColor, color);744if (ratio < worstRatio) {745worst = color;746worstRatio = ratio;747}748}749return worst;750}751752function firstCssUrl(value) {753const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);754if (!match) return '';755return (match[1] || match[2] || match[3] || '').trim();756}757758function getLayerValue(value, index = 0) {759return String(value || '').split(',')[index]?.trim() || '';760}761762function parsePositionToken(token, container, painted) {763if (!token || token === 'center') return (container - painted) / 2;764if (token === 'left' || token === 'top') return 0;765if (token === 'right' || token === 'bottom') return container - painted;766if (/%$/.test(token)) {767const pct = parseFloat(token) / 100;768return (container - painted) * pct;769}770if (/px$/.test(token)) return parseFloat(token) || 0;771return (container - painted) / 2;772}773774function parsePositionPair(positionValue) {775const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);776const first = tokens[0] || '50%';777if (tokens.length < 2) {778if (first === 'top' || first === 'bottom') return ['50%', first];779return [first, '50%'];780}781return [first, tokens[1] || '50%'];782}783784function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {785const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;786const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;787let paintedWidth = intrinsicWidth;788let paintedHeight = intrinsicHeight;789const size = String(sizeValue || 'auto').trim();790791if (size === 'cover' || size === 'contain') {792const scale = size === 'cover'793? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)794: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);795paintedWidth = intrinsicWidth * scale;796paintedHeight = intrinsicHeight * scale;797} else if (size && size !== 'auto') {798const parts = size.split(/\s+/);799const widthToken = parts[0];800const heightToken = parts[1] || 'auto';801if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);802else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;803if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);804else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);805else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;806}807808const [xToken, yToken] = parsePositionPair(positionValue);809const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);810const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);811return {812left: containerRect.left + positionX,813top: containerRect.top + positionY,814width: paintedWidth,815height: paintedHeight,816intrinsicWidth,817intrinsicHeight,818};819}820821function parseObjectPosition(positionValue) {822return parsePositionPair(positionValue);823}824825function resolveObjectImageRect(containerRect, image, style) {826const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;827const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;828const fit = style.objectFit || 'fill';829let paintedWidth = containerRect.width;830let paintedHeight = containerRect.height;831if (fit === 'contain' || fit === 'cover') {832const scale = fit === 'cover'833? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)834: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);835paintedWidth = intrinsicWidth * scale;836paintedHeight = intrinsicHeight * scale;837} else if (fit === 'none') {838paintedWidth = intrinsicWidth;839paintedHeight = intrinsicHeight;840} else if (fit === 'scale-down') {841const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);842paintedWidth = intrinsicWidth * containScale;843paintedHeight = intrinsicHeight * containScale;844}845const [xToken, yToken] = parseObjectPosition(style.objectPosition);846return {847left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),848top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),849width: paintedWidth,850height: paintedHeight,851intrinsicWidth,852intrinsicHeight,853};854}855856function pointToImageSource(point, paintedRect) {857if (858point.x < paintedRect.left ||859point.y < paintedRect.top ||860point.x > paintedRect.left + paintedRect.width ||861point.y > paintedRect.top + paintedRect.height862) {863return null;864}865return {866x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),867y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),868};869}870871async function loadVisualContrastImage(src) {872if (!src) return null;873if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);874const promise = new Promise(resolve => {875const img = new Image();876let settled = false;877const finish = value => {878if (settled) return;879settled = true;880clearTimeout(timer);881resolve(value);882};883const timer = setTimeout(() => finish(null), 800);884try {885const absolute = new URL(src, location.href);886if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {887img.crossOrigin = 'anonymous';888}889} catch {890// Let the browser resolve unusual URLs itself.891}892img.onload = () => finish(img);893img.onerror = () => finish(null);894img.src = src;895});896visualContrastImageCache.set(src, promise);897return promise;898}899900function sampleDrawablePixel(drawable, sourcePoint) {901if (visualContrastRasterCache.has(drawable)) {902const cached = visualContrastRasterCache.get(drawable);903if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };904try {905const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));906const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));907const data = cached.ctx.getImageData(x, y, 1, 1).data;908return {909status: 'sampled',910color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },911};912} catch (err) {913return {914status: 'unresolved',915reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',916};917}918}919920const canvas = document.createElement('canvas');921const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;922const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;923const maxRasterSide = 640;924const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));925canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));926canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));927const ctx = canvas.getContext('2d', { willReadFrequently: true });928if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };929try {930ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);931const cached = {932ctx,933width: canvas.width,934height: canvas.height,935scaleX: canvas.width / intrinsicWidth,936scaleY: canvas.height / intrinsicHeight,937};938visualContrastRasterCache.set(drawable, cached);939const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));940const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));941const data = ctx.getImageData(x, y, 1, 1).data;942return {943status: 'sampled',944color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },945};946} catch (err) {947const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';948visualContrastRasterCache.set(drawable, { ctx: null, reason });949return {950status: 'unresolved',951reason,952};953}954}955956async function sampleCssBackground(el, style, point, textColor) {957const rect = el.getBoundingClientRect();958const bgImage = style.backgroundImage || '';959if (bgImage && bgImage !== 'none') {960if (/gradient/i.test(bgImage)) {961const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));962if (color) return { status: 'sampled', color, method: 'analytic-gradient' };963}964if (/url\s*\(/i.test(bgImage)) {965const img = await loadVisualContrastImage(firstCssUrl(bgImage));966if (!img) return { status: 'unresolved', reason: 'image unavailable' };967const paintedRect = resolvePaintedImageRect(968rect,969img,970getLayerValue(style.backgroundSize) || 'auto',971getLayerValue(style.backgroundPosition) || '50% 50%',972);973const sourcePoint = pointToImageSource(point, paintedRect);974if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };975const sample = sampleDrawablePixel(img, sourcePoint);976if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };977return sample;978}979}980const bg = parseRgb(style.backgroundColor);981if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };982return { status: 'unresolved', reason: 'no readable background' };983}984985async function sampleImageElement(img, point) {986const rect = img.getBoundingClientRect();987const style = getComputedStyle(img);988const paintedRect = resolveObjectImageRect(rect, img, style);989const sourcePoint = pointToImageSource(point, paintedRect);990if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };991const sample = sampleDrawablePixel(img, sourcePoint);992if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };993994if (img.currentSrc || img.src) {995const loaded = await loadVisualContrastImage(img.currentSrc || img.src);996if (loaded) {997const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };998const loadedPoint = pointToImageSource(point, loadedRect);999if (loadedPoint) {1000const loadedSample = sampleDrawablePixel(loaded, loadedPoint);1001if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };1002}1003}1004}1005return sample;1006}10071008function textSamplePoints(rect) {1009const insetX = Math.min(12, Math.max(1, rect.width * 0.12));1010const insetY = Math.min(8, Math.max(1, rect.height * 0.22));1011const xs = rect.width < 281012? [rect.left + rect.width / 2]1013: [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];1014const ys = rect.height < 221015? [rect.top + rect.height / 2]1016: [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];1017const points = [];1018for (const y of ys) {1019for (const x of xs) {1020if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });1021}1022}1023return points;1024}10251026async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {1027if (depth > 8) {1028return { status: 'unresolved', reason: 'background stack too deep' };1029}1030const stack = typeof document.elementsFromPoint === 'function'1031? document.elementsFromPoint(point.x, point.y)1032: [];1033const selfIndex = stack.findIndex(node => node === el || el.contains(node));1034const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];1035const unresolved = [];10361037for (const node of nodes) {1038if (!node || node.nodeType !== 1) continue;1039if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;1040const tag = node.tagName?.toLowerCase();1041if (tag === 'img') {1042const sample = await sampleImageElement(node, point);1043if (sample.status === 'sampled') return sample;1044unresolved.push(sample.reason);1045continue;1046}1047if (tag === 'canvas' || tag === 'video') {1048const rect = node.getBoundingClientRect();1049const sourcePoint = pointToImageSource(point, {1050left: rect.left,1051top: rect.top,1052width: rect.width,1053height: rect.height,1054intrinsicWidth: node.width || node.videoWidth || rect.width,1055intrinsicHeight: node.height || node.videoHeight || rect.height,1056});1057if (sourcePoint) {1058const sample = sampleDrawablePixel(node, sourcePoint);1059if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };1060unresolved.push(sample.reason);1061}1062continue;1063}1064const style = getComputedStyle(node);1065const sample = await sampleCssBackground(node, style, point, textColor);1066if (sample.status === 'sampled') {1067if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;1068const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);1069if (under.status === 'sampled') {1070return {1071status: 'sampled',1072color: blendRgba(sample.color, under.color),1073method: `${sample.method}+alpha`,1074};1075}1076return sample;1077}1078unresolved.push(sample.reason);1079}10801081return {1082status: 'unresolved',1083reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',1084};1085}10861087async function analyzeVisualContrastCandidate(candidate) {1088let el;1089try {1090el = document.querySelector(candidate.selector);1091} catch {1092return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };1093}1094if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };1095if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };10961097const blockingReason = (candidate.reasons || []).find(reason =>1098reason === 'background-clip text' ||1099reason === 'blend mode' ||1100reason === 'filter' ||1101reason === 'backdrop filter' ||1102reason === 'opacity stack' ||1103reason === 'text shadow'1104);1105if (blockingReason) {1106return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };1107}11081109const style = getComputedStyle(el);1110const textColor = parseRgb(style.color) || candidate.textColor;1111if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };11121113const rect = getDirectTextRect(el) || el.getBoundingClientRect();1114if (!rect || rect.width < 4 || rect.height < 4) {1115return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };1116}11171118const points = textSamplePoints(rect);1119if (points.length === 0) {1120return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };1121}11221123const ratios = [];1124const methods = new Set();1125const unresolved = [];1126for (const point of points) {1127const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);1128if (sample.status !== 'sampled' || !sample.color) {1129unresolved.push(sample.reason);1130continue;1131}1132const fg = blendRgba(textColor, sample.color);1133ratios.push(contrastRatio(fg, sample.color));1134if (sample.method) methods.add(sample.method);1135}11361137if (ratios.length < Math.min(3, points.length)) {1138return {1139...candidate,1140status: 'unresolved',1141confidence: 'none',1142samples: ratios.length,1143reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',1144};1145}11461147ratios.sort((a, b) => a - b);1148const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];1149const measuredRatio = pick(10);1150const medianRatio = pick(50);1151const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';1152const method = [...methods].sort().join(', ') || 'browser-visual';1153const textLabel = candidate.text ? ` "${candidate.text}"` : '';1154const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;1155return {1156...candidate,1157status,1158confidence: method.includes('canvas-') ? 'high' : 'medium',1159method,1160ratio: measuredRatio,1161medianRatio,1162samples: ratios.length,1163finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,1164};1165}11661167function waitForVisualPaint() {1168return new Promise(resolve => {1169requestAnimationFrame(() => requestAnimationFrame(resolve));1170});1171}11721173async function analyzeVisualContrast(options = {}) {1174const candidates = collectVisualContrastCandidates(options);1175const results = [];1176const shouldScrollOffscreen = options.scrollOffscreen === true;1177const restoreScroll = { x: window.scrollX, y: window.scrollY };1178for (const candidate of candidates) {1179if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {1180window.scrollTo(restoreScroll.x, restoreScroll.y);1181await waitForVisualPaint();1182}1183let result = await analyzeVisualContrastCandidate(candidate);1184if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {1185let el = null;1186try {1187el = document.querySelector(candidate.selector);1188} catch {1189el = null;1190}1191if (el && typeof el.scrollIntoView === 'function') {1192el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });1193await waitForVisualPaint();1194result = await analyzeVisualContrastCandidate(candidate);1195}1196}1197results.push(result);1198}1199if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {1200window.scrollTo(restoreScroll.x, restoreScroll.y);1201}1202return results;1203}12041205function isElementHidden(el) {1206if (!el || el === document.body || el === document.documentElement) return false;1207if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });1208// Fallback: zero size or no offsetParent (covers display:none and detached subtrees)1209return el.offsetWidth === 0 && el.offsetHeight === 0;1210}12111212function serializeFindings(allFindings) {1213return allFindings.map(({ el, findings }) => ({1214selector: generateSelector(el),1215tagName: el.tagName?.toLowerCase() || 'unknown',1216rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)1217? el.getBoundingClientRect().toJSON() : null,1218isPageLevel: el === document.body || el === document.documentElement,1219isHidden: isElementHidden(el),1220findings: findings.map(f => {1221const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));1222return {1223type: f.type || f.id,1224category: ap ? ap.category : 'quality',1225severity: ap?.severity || 'warning',1226detail: f.detail || f.snippet,1227ignoreValue: f.ignoreValue || f.value || '',1228name: ap ? ap.name : (f.type || f.id),1229description: ap ? ap.description : '',1230};1231}),1232}));1233}12341235const printSummary = function(allFindings) {1236if (allFindings.length === 0) {1237console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');1238return;1239}1240console.group(1241`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,1242'color: oklch(84% 0.19 80.46); font-weight: bold'1243);1244for (const { el, findings } of allFindings) {1245for (const f of findings) {1246console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,1247'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);1248}1249}1250console.groupEnd();1251};12521253function addBrowserFindings(groupMap, el, findings) {1254if (!findings || findings.length === 0) return;1255const existing = groupMap.get(el);1256if (existing) existing.push(...findings);1257else groupMap.set(el, [...findings]);1258}12591260function browserFindingsFromMap(groupMap) {1261return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));1262}12631264const DESIGN_COLOR_TOLERANCE = 6;1265const DESIGN_RADIUS_TOLERANCE_PX = 0.5;1266const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);12671268function normalizeBrowserFontName(value) {1269return String(value || '')1270.trim()1271.replace(/^["']|["']$/g, '')1272.replace(/\+/g, ' ')1273.replace(/\s+/g, ' ')1274.toLowerCase();1275}12761277function browserPrimaryFont(stack) {1278if (!stack || /var\(/i.test(stack)) return '';1279return String(stack || '')1280.split(',')1281.map(normalizeBrowserFontName)1282.find(font => font && !GENERIC_FONTS.has(font)) || '';1283}12841285function browserDesignSystemConfig() {1286const raw = window.__IMPECCABLE_CONFIG__?.designSystem;1287if (!raw?.present) return null;1288const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));1289const allowedColors = (raw.allowedColors || [])1290.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))1291.map(color => ({ r: color.r, g: color.g, b: color.b }));1292const allowedRadii = (raw.allowedRadii || [])1293.map(Number)1294.filter(px => Number.isFinite(px));1295return {1296present: true,1297hasFonts: raw.hasFonts === true && allowedFonts.size > 0,1298allowedFonts,1299hasColors: raw.hasColors === true && allowedColors.length > 0,1300allowedColors,1301hasRadii: raw.hasRadii === true && allowedRadii.length > 0,1302allowedRadii,1303hasPillRadius: raw.hasPillRadius === true,1304};1305}13061307function browserColorsClose(a, b) {1308if (!a || !b) return false;1309return Math.max(1310Math.abs(a.r - b.r),1311Math.abs(a.g - b.g),1312Math.abs(a.b - b.b),1313) <= DESIGN_COLOR_TOLERANCE;1314}13151316function isBrowserDesignColorAllowed(raw, designSystem) {1317if (!designSystem?.hasColors) return true;1318const text = String(raw || '').trim().toLowerCase();1319if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;1320if (text.includes('var(')) return true;1321const parsed = parseAnyColor(text);1322if (!parsed) return true;1323if ((parsed.a ?? 1) <= 0.05) return true;1324return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));1325}13261327function isBrowserTransparentCss(value) {1328const text = String(value || '').trim().toLowerCase();1329if (!text || text === 'transparent') return true;1330const parsed = parseAnyColor(text);1331return parsed ? (parsed.a ?? 1) <= 0.05 : false;1332}13331334function isBrowserDesignRadiusAllowed(raw, designSystem) {1335if (!designSystem?.hasRadii) return true;1336const text = String(raw || '').trim().toLowerCase();1337if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;1338if (text.includes('var(') || text.includes('%')) return true;1339const px = resolveLengthPx(text, 16);1340if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;1341if (designSystem.hasPillRadius && px >= 99) return true;1342return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);1343}13441345function browserRadiusTokens(value) {1346return String(value || '')1347.replace(/\s*\/\s*/g, ' ')1348.split(/\s+/)1349.map(token => token.trim())1350.filter(Boolean);1351}13521353function browserHasDirectText(el) {1354return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);1355}13561357function browserSampleText(el) {1358const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();1359return text ? ` "${text.slice(0, 40)}"` : '';1360}13611362function shouldSkipDesignElement(el) {1363const tag = el.tagName?.toLowerCase?.() || '';1364return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);1365}13661367function checkElementDesignSystemDOM(el, designSystem, seen) {1368if (!designSystem?.present || shouldSkipDesignElement(el)) return [];1369const findings = [];1370const tag = el.tagName?.toLowerCase?.() || 'unknown';1371const style = getComputedStyle(el);13721373if (designSystem.hasFonts && browserHasDirectText(el)) {1374const font = browserPrimaryFont(style.fontFamily || '');1375if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {1376seen.fonts.add(font);1377findings.push({1378type: 'design-system-font',1379detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,1380ignoreValue: font,1381});1382}1383}13841385if (designSystem.hasColors) {1386const colorChecks = [];1387if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);1388if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);1389for (const side of ['Top', 'Right', 'Bottom', 'Left']) {1390if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {1391colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);1392}1393}1394if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);13951396for (const [kind, raw] of colorChecks) {1397const label = String(raw || '').trim().replace(/\s+/g, ' ');1398if (isBrowserDesignColorAllowed(label, designSystem)) continue;1399const key = `${kind}:${label}`;1400if (seen.colors.has(key)) continue;1401seen.colors.add(key);1402findings.push({1403type: 'design-system-color',1404detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,1405ignoreValue: label,1406});1407}1408}14091410if (designSystem.hasRadii) {1411for (const token of browserRadiusTokens(style.borderRadius || '')) {1412if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;1413if (seen.radii.has(token)) continue;1414seen.radii.add(token);1415findings.push({1416type: 'design-system-radius',1417detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,1418ignoreValue: token,1419});1420}1421}14221423return findings;1424}14251426function decodeBrowserGoogleFamily(value) {1427const family = String(value || '').split(':')[0].replace(/\+/g, ' ');1428try {1429return decodeURIComponent(family);1430} catch {1431return family;1432}1433}14341435function checkBrowserDesignSystemSources(designSystem, seen) {1436if (!designSystem?.hasFonts) return [];1437const findings = [];1438for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {1439const href = link.getAttribute('href') || '';1440for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {1441const display = decodeBrowserGoogleFamily(match[1]);1442const font = normalizeBrowserFontName(display);1443if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;1444seen.fonts.add(font);1445findings.push({1446type: 'design-system-font',1447detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,1448ignoreValue: display,1449});1450}1451}1452return findings;1453}14541455function collectBrowserFindings() {1456const groupMap = new Map();1457const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];1458const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);1459const designSystem = browserDesignSystemConfig();1460const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };1461// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a1462// real browser env (detector page, live overlay, extension) running every1463// check is free, so we always surface them; the gating is purely a CLI1464// output concern, applied in the Node engines' detect* return paths.14651466for (const el of document.querySelectorAll('*')) {1467// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)1468if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;1469// Skip browser extension elements (Claude, etc.)1470const elId = el.id || '';1471if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;1472// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).1473// These are inspector chrome, not part of the user's design.1474if (el.closest('[id^="impeccable-live-"]')) continue;1475// Skip html/body -- page-level findings go in the banner, not a full-page overlay1476if (el === document.body || el === document.documentElement) continue;14771478const findings = [1479...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1480...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1481...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1482...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1483...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1484...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1485...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1486...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1487...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })),1488...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1489...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1490...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1491...checkElementDesignSystemDOM(el, designSystem, designSeen),1492].filter(f => _ruleOk(f.type));14931494addBrowserFindings(groupMap, el, findings);14951496// Hero eyebrow: the offending element is the eyebrow above the heading,1497// not the heading itself — highlight the previous sibling instead.1498const eyebrowFindings = checkElementHeroEyebrowDOM(el)1499.map(f => ({ type: f.id, detail: f.snippet }))1500.filter(f => _ruleOk(f.type));1501if (eyebrowFindings.length > 0 && el.previousElementSibling) {1502addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings);1503}1504}15051506const pageLevelFindings = [];15071508const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)1509.filter(f => _ruleOk(f.type));1510if (designSourceFindings.length > 0) {1511pageLevelFindings.push(...designSourceFindings);1512addBrowserFindings(groupMap, document.body, designSourceFindings);1513}15141515const typoFindings = checkTypography().filter(f => _ruleOk(f.type));1516if (typoFindings.length > 0) {1517pageLevelFindings.push(...typoFindings);1518addBrowserFindings(groupMap, document.body, typoFindings);1519}15201521const sectionKickerFindings = checkRepeatedSectionKickersDOM()1522.map(f => ({ type: f.id, detail: f.snippet }))1523.filter(f => _ruleOk(f.type));1524if (sectionKickerFindings.length > 0) {1525pageLevelFindings.push(...sectionKickerFindings);1526addBrowserFindings(groupMap, document.body, sectionKickerFindings);1527}15281529const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));1530for (const f of layoutFindings) {1531const el = f.el || document.body;1532addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);1533}15341535// Page-level quality checks (headings, etc.)1536const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));1537if (qualityFindings.length > 0) {1538pageLevelFindings.push(...qualityFindings);1539addBrowserFindings(groupMap, document.body, qualityFindings);1540}15411542const creamFindings = checkCreamPalette(document)1543.map(f => ({ type: f.id, detail: f.snippet }))1544.filter(f => _ruleOk(f.type));1545if (creamFindings.length > 0) {1546pageLevelFindings.push(...creamFindings);1547addBrowserFindings(groupMap, document.body, creamFindings);1548}15491550// Regex-on-HTML checks (shared with Node)1551// Clone the document and strip impeccable-live overlay nodes before the1552// regex scan, so the inspector's own inline styles (transitions on top/1553// left/width/height, etc.) don't register as page anti-patterns.1554const docClone = document.documentElement.cloneNode(true);1555for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {1556node.remove();1557}1558const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);1559if (htmlPatternFindings.length > 0) {1560const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));1561pageLevelFindings.push(...mapped);1562addBrowserFindings(groupMap, document.body, mapped);1563}15641565return {1566groupMap,1567allFindings: browserFindingsFromMap(groupMap),1568pageLevelFindings,1569};1570}15711572function shouldRunVisualContrast(options = {}) {1573return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;1574}15751576function visualContrastOptions(options = {}) {1577const config = window.__IMPECCABLE_CONFIG__ || {};1578const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'1579? options.scrollOffscreen1580: typeof options.visualContrastScrollOffscreen === 'boolean'1581? options.visualContrastScrollOffscreen1582: typeof config.visualContrastScrollOffscreen === 'boolean'1583? config.visualContrastScrollOffscreen1584: false;1585return {1586...options,1587maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)1588? options.visualContrastMaxCandidates1589: Number.isFinite(options.maxCandidates)1590? options.maxCandidates1591: Number.isFinite(config.visualContrastMaxCandidates)1592? config.visualContrastMaxCandidates1593: undefined,1594scrollOffscreen,1595};1596}15971598let lastVisualContrastAnalyses = [];1599let lazyVisualContrastObserver = null;1600let lazyVisualContrastPending = new WeakMap();1601const lazyVisualContrastResolving = new WeakSet();1602let scanGeneration = 0;16031604function rememberVisualContrastAnalysis(result) {1605if (!result?.selector) {1606lastVisualContrastAnalyses.push(result);1607return;1608}1609const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);1610if (idx >= 0) lastVisualContrastAnalyses[idx] = result;1611else lastVisualContrastAnalyses.push(result);1612}16131614function disconnectLazyVisualContrastObserver() {1615if (lazyVisualContrastObserver) {1616lazyVisualContrastObserver.disconnect();1617lazyVisualContrastObserver = null;1618}1619lazyVisualContrastPending = new WeakMap();1620}16211622function addVisualContrastResult(groupMap, result, options = {}) {1623if (result.status !== 'fail' || !result.finding || !result.selector) return false;1624let el = null;1625try {1626el = document.querySelector(result.selector);1627} catch {1628el = null;1629}1630if (!el) return false;1631const findingType = result.finding.type || result.finding.id || 'low-contrast';1632const existing = groupMap.get(el) || [];1633if (existing.some(f => (f.type || f.id) === findingType)) return false;1634addBrowserFindings(groupMap, el, [{1635type: findingType,1636detail: result.finding.detail || result.finding.snippet,1637}]);1638if (options.decorate && el !== document.body && el !== document.documentElement) {1639highlight(el, groupMap.get(el) || []);1640}1641return true;1642}16431644function scanResultMeta(options = {}) {1645const scanId = options.scanId;1646if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};1647return { scanId: String(scanId) };1648}16491650function postSerializedFindings(groupMap, options = {}) {1651if (!EXTENSION_MODE) return;1652const allFindings = browserFindingsFromMap(groupMap);1653window.postMessage({1654source: 'impeccable-results',1655findings: serializeFindings(allFindings),1656count: allFindings.length,1657...scanResultMeta(options),1658}, '*');1659}16601661function postExtensionError(err) {1662if (!EXTENSION_MODE) return;1663window.postMessage({1664source: 'impeccable-error',1665message: err?.message || String(err),1666}, '*');1667}16681669function reportVisualContrastError(err, detail = {}) {1670window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {1671detail: {1672...detail,1673message: err?.message || String(err),1674},1675}));1676if (EXTENSION_MODE) {1677postExtensionError(err);1678} else {1679console.warn('[impeccable] visual contrast scan failed', err);1680}1681}16821683function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {1684disconnectLazyVisualContrastObserver();1685if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;1686if (typeof IntersectionObserver === 'undefined') return;1687const unresolved = (analyses || []).filter(result =>1688result?.status === 'unresolved' &&1689result.reason === 'text outside viewport' &&1690result.selector1691);1692if (unresolved.length === 0) return;1693const generation = runtime.generation || scanGeneration;16941695lazyVisualContrastObserver = new IntersectionObserver((entries) => {1696for (const entry of entries) {1697if (!entry.isIntersecting) continue;1698const el = entry.target;1699const candidate = lazyVisualContrastPending.get(el);1700if (!candidate || lazyVisualContrastResolving.has(el)) continue;1701lazyVisualContrastObserver?.unobserve(el);1702lazyVisualContrastPending.delete(el);1703lazyVisualContrastResolving.add(el);1704waitForVisualPaint()1705.then(() => analyzeVisualContrastCandidate(candidate))1706.then(result => {1707if (generation !== scanGeneration) return;1708rememberVisualContrastAnalysis(result);1709const added = addVisualContrastResult(groupMap, result, { decorate: true });1710if (added) {1711postSerializedFindings(groupMap, options);1712window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {1713detail: {1714selector: result.selector,1715status: result.status,1716finding: result.finding || null,1717},1718}));1719}1720})1721.catch(err => {1722reportVisualContrastError(err, { selector: candidate.selector });1723})1724.finally(() => {1725lazyVisualContrastResolving.delete(el);1726});1727}1728}, { threshold: 0.5 });17291730for (const candidate of unresolved) {1731let el = null;1732try {1733el = document.querySelector(candidate.selector);1734} catch {1735el = null;1736}1737if (!el) continue;1738lazyVisualContrastPending.set(el, candidate);1739lazyVisualContrastObserver.observe(el);1740}1741}17421743async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {1744if (!shouldRunVisualContrast(options)) {1745lastVisualContrastAnalyses = [];1746disconnectLazyVisualContrastObserver();1747return [];1748}1749const resolvedOptions = visualContrastOptions(options);1750const analyses = await analyzeVisualContrast(resolvedOptions);1751if (runtime.generation && runtime.generation !== scanGeneration) return analyses;1752lastVisualContrastAnalyses = analyses;1753for (const result of analyses) {1754addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });1755}1756if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);1757return analyses;1758}17591760async function collectBrowserFindingsAsync(options = {}, runtime = {}) {1761const collected = collectBrowserFindings();1762await addVisualContrastFindings(collected.groupMap, options, runtime);1763return {1764...collected,1765allFindings: browserFindingsFromMap(collected.groupMap),1766visualContrastAnalyses: lastVisualContrastAnalyses,1767};1768}17691770function clearOverlays() {1771scanGeneration += 1;1772disconnectLazyVisualContrastObserver();1773for (const o of [...overlays]) detachOverlay(o);1774overlays.length = 0;1775visibilityObserver.disconnect();1776overlayIndex = 0;1777}17781779function renderBrowserFindings(collected, options = {}) {1780const { allFindings, pageLevelFindings } = collected;17811782for (const { el, findings } of allFindings) {1783if (el === document.body || el === document.documentElement) continue;1784highlight(el, findings);1785}17861787if (pageLevelFindings.length > 0) {1788showPageBanner(pageLevelFindings);1789}17901791if (!EXTENSION_MODE) printSummary(allFindings);17921793// In extension mode, post serialized results for the DevTools panel1794if (EXTENSION_MODE) {1795window.postMessage({1796source: 'impeccable-results',1797findings: serializeFindings(allFindings),1798count: allFindings.length,1799...scanResultMeta(options),1800}, '*');1801}18021803// After this scan completes, all subsequent reveals are instant (no stagger, no animation)1804setTimeout(() => { firstScanDone = true; }, 1000);18051806return allFindings;1807}18081809let firstScanDone = false;1810const scan = function(options = {}) {1811clearOverlays();1812const generation = scanGeneration;1813const collected = collectBrowserFindings();1814const allFindings = renderBrowserFindings(collected, options);1815if (shouldRunVisualContrast(options)) {1816addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })1817.then(() => {1818if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);1819})1820.catch(err => {1821reportVisualContrastError(err);1822});1823}1824return allFindings;1825};18261827const scanAsync = async function(options = {}) {1828clearOverlays();1829const generation = scanGeneration;1830if (shouldRunVisualContrast(options)) {1831const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });1832if (generation !== scanGeneration) return [];1833return renderBrowserFindings(collected, options);1834}1835lastVisualContrastAnalyses = [];1836return renderBrowserFindings(collectBrowserFindings(), options);1837};18381839const detect = function(options = {}) {1840lastVisualContrastAnalyses = [];1841const { allFindings } = collectBrowserFindings();1842return options.serialize === false ? allFindings : serializeFindings(allFindings);1843};18441845const detectAsync = async function(options = {}) {1846if (shouldRunVisualContrast(options)) {1847const { allFindings } = await collectBrowserFindingsAsync(options);1848return options.serialize === false ? allFindings : serializeFindings(allFindings);1849}1850lastVisualContrastAnalyses = [];1851const { allFindings } = collectBrowserFindings();1852return options.serialize === false ? allFindings : serializeFindings(allFindings);1853};18541855if (EXTENSION_MODE) {1856// Extension mode: listen for commands, don't auto-scan1857window.addEventListener('message', (e) => {1858if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;1859if (e.data.action === 'scan') {1860if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;1861try {1862scan(e.data.config || {});1863} catch (err) {1864postExtensionError(err);1865}1866}1867if (e.data.action === 'toggle-overlays') {1868const visible = !document.body.classList.contains('impeccable-hidden');1869document.body.classList.toggle('impeccable-hidden', visible);1870window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');1871}1872if (e.data.action === 'remove') {1873clearOverlays();1874styleEl.remove();1875if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }1876document.body.classList.remove('impeccable-hidden');1877}1878if (e.data.action === 'highlight') {1879try {1880const target = e.data.selector ? document.querySelector(e.data.selector) : null;1881if (target) {1882// Scroll first so positionOverlay reads the post-scroll rect1883if (!isInViewport(target) && target.scrollIntoView) {1884target.scrollIntoView({ behavior: 'instant', block: 'center' });1885}1886for (const o of overlays) {1887if (o.classList.contains('impeccable-banner')) continue;1888const isMatch = o._targetEl === target;1889o.classList.toggle('impeccable-spotlight', isMatch);1890o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);1891if (isMatch) {1892// Force the matching overlay visible immediately, don't wait for IntersectionObserver1893o.style.display = '';1894o.style.animation = 'none';1895o.classList.add('impeccable-visible');1896o._revealed = true;1897positionOverlay(o);1898}1899}1900showSpotlight(target);1901}1902} catch { /* invalid selector */ }1903}1904if (e.data.action === 'unhighlight') {1905hideSpotlight();1906for (const o of overlays) {1907o.classList.remove('impeccable-spotlight');1908o.classList.remove('impeccable-spotlight-dimmed');1909}1910}1911});1912window.postMessage({ source: 'impeccable-ready' }, '*');1913} else {1914if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {1915const runAutoScan = () => {1916try {1917scan();1918} catch (err) {1919console.warn('[impeccable] scan failed', err);1920}1921};1922if (document.readyState === 'loading') {1923document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));1924} else {1925setTimeout(runAutoScan, 100);1926}1927}1928}19291930window.impeccableDetect = detect;1931window.impeccableDetectAsync = detectAsync;1932window.impeccableScan = scan;1933window.impeccableScanAsync = scanAsync;1934window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;1935window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;1936window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();1937}1938