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;663664const tag = el.tagName.toLowerCase();665const style = getComputedStyle(el);666if (style.display === 'none' || style.visibility === 'hidden') continue;667const directText = getDirectText(el);668const hasDirectText = directText.trim().length > 0;669if (!hasDirectText || isEmojiOnlyText(directText)) continue;670671const bgColor = readOwnBackgroundColor(el, style);672const isStyledButton = (tag === 'a' || tag === 'button')673&& bgColor && bgColor.a > 0.5;674if (SAFE_TAGS.has(tag) && !isStyledButton) continue;675676const rect = getDirectTextRect(el) || el.getBoundingClientRect();677if (!rect || rect.width < 4 || rect.height < 4) continue;678679const reasons = collectVisualContrastReasons(el, style);680if (reasons.length === 0) continue;681682const textColor = parseRgb(style.color);683const fontSize = parseFloat(style.fontSize) || 16;684const fontWeight = parseInt(style.fontWeight) || 400;685const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);686const threshold = isLargeText ? 3.0 : 4.5;687const clip = {688x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),689y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),690width: Math.max(1, Math.ceil(rect.width + 4)),691height: Math.max(1, Math.ceil(rect.height + 4)),692};693694candidates.push({695selector: generateSelector(el),696tagName: tag,697text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),698threshold,699reasons,700clip,701textColor,702preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>703reason === 'opacity stack' ||704reason === 'blend mode' ||705reason === 'filter' ||706reason === 'backdrop filter' ||707reason === 'background-clip text'708),709backgroundClipText: reasons.includes('background-clip text'),710});711}712return candidates;713}714715const visualContrastImageCache = new Map();716const visualContrastRasterCache = new WeakMap();717718function clampByte(value) {719return Math.max(0, Math.min(255, Math.round(value)));720}721722function blendRgba(fg, bg) {723if (!fg) return bg || null;724if (!bg || fg.a == null || fg.a >= 0.999) {725return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };726}727const alpha = Math.max(0, Math.min(1, fg.a));728return {729r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),730g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),731b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),732a: 1,733};734}735736function pickWorstContrastColor(textColor, colors) {737const usable = (colors || []).filter(Boolean);738if (!usable.length) return null;739let worst = usable[0];740let worstRatio = contrastRatio(textColor, worst);741for (const color of usable.slice(1)) {742const ratio = contrastRatio(textColor, color);743if (ratio < worstRatio) {744worst = color;745worstRatio = ratio;746}747}748return worst;749}750751function firstCssUrl(value) {752const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);753if (!match) return '';754return (match[1] || match[2] || match[3] || '').trim();755}756757function getLayerValue(value, index = 0) {758return String(value || '').split(',')[index]?.trim() || '';759}760761function parsePositionToken(token, container, painted) {762if (!token || token === 'center') return (container - painted) / 2;763if (token === 'left' || token === 'top') return 0;764if (token === 'right' || token === 'bottom') return container - painted;765if (/%$/.test(token)) {766const pct = parseFloat(token) / 100;767return (container - painted) * pct;768}769if (/px$/.test(token)) return parseFloat(token) || 0;770return (container - painted) / 2;771}772773function parsePositionPair(positionValue) {774const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);775const first = tokens[0] || '50%';776if (tokens.length < 2) {777if (first === 'top' || first === 'bottom') return ['50%', first];778return [first, '50%'];779}780return [first, tokens[1] || '50%'];781}782783function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {784const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;785const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;786let paintedWidth = intrinsicWidth;787let paintedHeight = intrinsicHeight;788const size = String(sizeValue || 'auto').trim();789790if (size === 'cover' || size === 'contain') {791const scale = size === 'cover'792? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)793: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);794paintedWidth = intrinsicWidth * scale;795paintedHeight = intrinsicHeight * scale;796} else if (size && size !== 'auto') {797const parts = size.split(/\s+/);798const widthToken = parts[0];799const heightToken = parts[1] || 'auto';800if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);801else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;802if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);803else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);804else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;805}806807const [xToken, yToken] = parsePositionPair(positionValue);808const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);809const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);810return {811left: containerRect.left + positionX,812top: containerRect.top + positionY,813width: paintedWidth,814height: paintedHeight,815intrinsicWidth,816intrinsicHeight,817};818}819820function parseObjectPosition(positionValue) {821return parsePositionPair(positionValue);822}823824function resolveObjectImageRect(containerRect, image, style) {825const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;826const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;827const fit = style.objectFit || 'fill';828let paintedWidth = containerRect.width;829let paintedHeight = containerRect.height;830if (fit === 'contain' || fit === 'cover') {831const scale = fit === 'cover'832? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)833: Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);834paintedWidth = intrinsicWidth * scale;835paintedHeight = intrinsicHeight * scale;836} else if (fit === 'none') {837paintedWidth = intrinsicWidth;838paintedHeight = intrinsicHeight;839} else if (fit === 'scale-down') {840const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);841paintedWidth = intrinsicWidth * containScale;842paintedHeight = intrinsicHeight * containScale;843}844const [xToken, yToken] = parseObjectPosition(style.objectPosition);845return {846left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),847top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),848width: paintedWidth,849height: paintedHeight,850intrinsicWidth,851intrinsicHeight,852};853}854855function pointToImageSource(point, paintedRect) {856if (857point.x < paintedRect.left ||858point.y < paintedRect.top ||859point.x > paintedRect.left + paintedRect.width ||860point.y > paintedRect.top + paintedRect.height861) {862return null;863}864return {865x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),866y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),867};868}869870async function loadVisualContrastImage(src) {871if (!src) return null;872if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);873const promise = new Promise(resolve => {874const img = new Image();875let settled = false;876const finish = value => {877if (settled) return;878settled = true;879clearTimeout(timer);880resolve(value);881};882const timer = setTimeout(() => finish(null), 800);883try {884const absolute = new URL(src, location.href);885if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {886img.crossOrigin = 'anonymous';887}888} catch {889// Let the browser resolve unusual URLs itself.890}891img.onload = () => finish(img);892img.onerror = () => finish(null);893img.src = src;894});895visualContrastImageCache.set(src, promise);896return promise;897}898899function sampleDrawablePixel(drawable, sourcePoint) {900if (visualContrastRasterCache.has(drawable)) {901const cached = visualContrastRasterCache.get(drawable);902if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };903try {904const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));905const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));906const data = cached.ctx.getImageData(x, y, 1, 1).data;907return {908status: 'sampled',909color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },910};911} catch (err) {912return {913status: 'unresolved',914reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',915};916}917}918919const canvas = document.createElement('canvas');920const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;921const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;922const maxRasterSide = 640;923const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));924canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));925canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));926const ctx = canvas.getContext('2d', { willReadFrequently: true });927if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };928try {929ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);930const cached = {931ctx,932width: canvas.width,933height: canvas.height,934scaleX: canvas.width / intrinsicWidth,935scaleY: canvas.height / intrinsicHeight,936};937visualContrastRasterCache.set(drawable, cached);938const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));939const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));940const data = ctx.getImageData(x, y, 1, 1).data;941return {942status: 'sampled',943color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },944};945} catch (err) {946const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';947visualContrastRasterCache.set(drawable, { ctx: null, reason });948return {949status: 'unresolved',950reason,951};952}953}954955async function sampleCssBackground(el, style, point, textColor) {956const rect = el.getBoundingClientRect();957const bgImage = style.backgroundImage || '';958if (bgImage && bgImage !== 'none') {959if (/gradient/i.test(bgImage)) {960const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));961if (color) return { status: 'sampled', color, method: 'analytic-gradient' };962}963if (/url\s*\(/i.test(bgImage)) {964const img = await loadVisualContrastImage(firstCssUrl(bgImage));965if (!img) return { status: 'unresolved', reason: 'image unavailable' };966const paintedRect = resolvePaintedImageRect(967rect,968img,969getLayerValue(style.backgroundSize) || 'auto',970getLayerValue(style.backgroundPosition) || '50% 50%',971);972const sourcePoint = pointToImageSource(point, paintedRect);973if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };974const sample = sampleDrawablePixel(img, sourcePoint);975if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };976return sample;977}978}979const bg = parseRgb(style.backgroundColor);980if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };981return { status: 'unresolved', reason: 'no readable background' };982}983984async function sampleImageElement(img, point) {985const rect = img.getBoundingClientRect();986const style = getComputedStyle(img);987const paintedRect = resolveObjectImageRect(rect, img, style);988const sourcePoint = pointToImageSource(point, paintedRect);989if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };990const sample = sampleDrawablePixel(img, sourcePoint);991if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };992993if (img.currentSrc || img.src) {994const loaded = await loadVisualContrastImage(img.currentSrc || img.src);995if (loaded) {996const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };997const loadedPoint = pointToImageSource(point, loadedRect);998if (loadedPoint) {999const loadedSample = sampleDrawablePixel(loaded, loadedPoint);1000if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };1001}1002}1003}1004return sample;1005}10061007function textSamplePoints(rect) {1008const insetX = Math.min(12, Math.max(1, rect.width * 0.12));1009const insetY = Math.min(8, Math.max(1, rect.height * 0.22));1010const xs = rect.width < 281011? [rect.left + rect.width / 2]1012: [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];1013const ys = rect.height < 221014? [rect.top + rect.height / 2]1015: [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];1016const points = [];1017for (const y of ys) {1018for (const x of xs) {1019if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });1020}1021}1022return points;1023}10241025async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {1026if (depth > 8) {1027return { status: 'unresolved', reason: 'background stack too deep' };1028}1029const stack = typeof document.elementsFromPoint === 'function'1030? document.elementsFromPoint(point.x, point.y)1031: [];1032const selfIndex = stack.findIndex(node => node === el || el.contains(node));1033const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];1034const unresolved = [];10351036for (const node of nodes) {1037if (!node || node.nodeType !== 1) continue;1038if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;1039const tag = node.tagName?.toLowerCase();1040if (tag === 'img') {1041const sample = await sampleImageElement(node, point);1042if (sample.status === 'sampled') return sample;1043unresolved.push(sample.reason);1044continue;1045}1046if (tag === 'canvas' || tag === 'video') {1047const rect = node.getBoundingClientRect();1048const sourcePoint = pointToImageSource(point, {1049left: rect.left,1050top: rect.top,1051width: rect.width,1052height: rect.height,1053intrinsicWidth: node.width || node.videoWidth || rect.width,1054intrinsicHeight: node.height || node.videoHeight || rect.height,1055});1056if (sourcePoint) {1057const sample = sampleDrawablePixel(node, sourcePoint);1058if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };1059unresolved.push(sample.reason);1060}1061continue;1062}1063const style = getComputedStyle(node);1064const sample = await sampleCssBackground(node, style, point, textColor);1065if (sample.status === 'sampled') {1066if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;1067const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);1068if (under.status === 'sampled') {1069return {1070status: 'sampled',1071color: blendRgba(sample.color, under.color),1072method: `${sample.method}+alpha`,1073};1074}1075return sample;1076}1077unresolved.push(sample.reason);1078}10791080return {1081status: 'unresolved',1082reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',1083};1084}10851086async function analyzeVisualContrastCandidate(candidate) {1087let el;1088try {1089el = document.querySelector(candidate.selector);1090} catch {1091return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };1092}1093if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };10941095const blockingReason = (candidate.reasons || []).find(reason =>1096reason === 'background-clip text' ||1097reason === 'blend mode' ||1098reason === 'filter' ||1099reason === 'backdrop filter' ||1100reason === 'opacity stack' ||1101reason === 'text shadow'1102);1103if (blockingReason) {1104return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };1105}11061107const style = getComputedStyle(el);1108const textColor = parseRgb(style.color) || candidate.textColor;1109if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };11101111const rect = getDirectTextRect(el) || el.getBoundingClientRect();1112if (!rect || rect.width < 4 || rect.height < 4) {1113return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };1114}11151116const points = textSamplePoints(rect);1117if (points.length === 0) {1118return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };1119}11201121const ratios = [];1122const methods = new Set();1123const unresolved = [];1124for (const point of points) {1125const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);1126if (sample.status !== 'sampled' || !sample.color) {1127unresolved.push(sample.reason);1128continue;1129}1130const fg = blendRgba(textColor, sample.color);1131ratios.push(contrastRatio(fg, sample.color));1132if (sample.method) methods.add(sample.method);1133}11341135if (ratios.length < Math.min(3, points.length)) {1136return {1137...candidate,1138status: 'unresolved',1139confidence: 'none',1140samples: ratios.length,1141reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',1142};1143}11441145ratios.sort((a, b) => a - b);1146const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];1147const measuredRatio = pick(10);1148const medianRatio = pick(50);1149const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';1150const method = [...methods].sort().join(', ') || 'browser-visual';1151const textLabel = candidate.text ? ` "${candidate.text}"` : '';1152const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;1153return {1154...candidate,1155status,1156confidence: method.includes('canvas-') ? 'high' : 'medium',1157method,1158ratio: measuredRatio,1159medianRatio,1160samples: ratios.length,1161finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,1162};1163}11641165function waitForVisualPaint() {1166return new Promise(resolve => {1167requestAnimationFrame(() => requestAnimationFrame(resolve));1168});1169}11701171async function analyzeVisualContrast(options = {}) {1172const candidates = collectVisualContrastCandidates(options);1173const results = [];1174const shouldScrollOffscreen = options.scrollOffscreen === true;1175const restoreScroll = { x: window.scrollX, y: window.scrollY };1176for (const candidate of candidates) {1177if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {1178window.scrollTo(restoreScroll.x, restoreScroll.y);1179await waitForVisualPaint();1180}1181let result = await analyzeVisualContrastCandidate(candidate);1182if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {1183let el = null;1184try {1185el = document.querySelector(candidate.selector);1186} catch {1187el = null;1188}1189if (el && typeof el.scrollIntoView === 'function') {1190el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });1191await waitForVisualPaint();1192result = await analyzeVisualContrastCandidate(candidate);1193}1194}1195results.push(result);1196}1197if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {1198window.scrollTo(restoreScroll.x, restoreScroll.y);1199}1200return results;1201}12021203function isElementHidden(el) {1204if (!el || el === document.body || el === document.documentElement) return false;1205if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });1206// Fallback: zero size or no offsetParent (covers display:none and detached subtrees)1207return el.offsetWidth === 0 && el.offsetHeight === 0;1208}12091210function serializeFindings(allFindings) {1211return allFindings.map(({ el, findings }) => ({1212selector: generateSelector(el),1213tagName: el.tagName?.toLowerCase() || 'unknown',1214rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)1215? el.getBoundingClientRect().toJSON() : null,1216isPageLevel: el === document.body || el === document.documentElement,1217isHidden: isElementHidden(el),1218findings: findings.map(f => {1219const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));1220return {1221type: f.type || f.id,1222category: ap ? ap.category : 'quality',1223severity: ap?.severity || 'warning',1224detail: f.detail || f.snippet,1225name: ap ? ap.name : (f.type || f.id),1226description: ap ? ap.description : '',1227};1228}),1229}));1230}12311232const printSummary = function(allFindings) {1233if (allFindings.length === 0) {1234console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');1235return;1236}1237console.group(1238`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,1239'color: oklch(84% 0.19 80.46); font-weight: bold'1240);1241for (const { el, findings } of allFindings) {1242for (const f of findings) {1243console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,1244'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);1245}1246}1247console.groupEnd();1248};12491250function addBrowserFindings(groupMap, el, findings) {1251if (!findings || findings.length === 0) return;1252const existing = groupMap.get(el);1253if (existing) existing.push(...findings);1254else groupMap.set(el, [...findings]);1255}12561257function browserFindingsFromMap(groupMap) {1258return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));1259}12601261function collectBrowserFindings() {1262const groupMap = new Map();1263const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];1264const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);1265// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a1266// real browser env (detector page, live overlay, extension) running every1267// check is free, so we always surface them; the gating is purely a CLI1268// output concern, applied in the Node engines' detect* return paths.12691270for (const el of document.querySelectorAll('*')) {1271// Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)1272if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;1273// Skip browser extension elements (Claude, etc.)1274const elId = el.id || '';1275if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;1276// Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).1277// These are inspector chrome, not part of the user's design.1278if (el.closest('[id^="impeccable-live-"]')) continue;1279// Skip html/body -- page-level findings go in the banner, not a full-page overlay1280if (el === document.body || el === document.documentElement) continue;12811282const findings = [1283...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1284...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1285...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1286...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1287...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1288...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1289...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1290...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1291...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })),1292...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1293...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1294...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),1295].filter(f => _ruleOk(f.type));12961297addBrowserFindings(groupMap, el, findings);12981299// Hero eyebrow: the offending element is the eyebrow above the heading,1300// not the heading itself — highlight the previous sibling instead.1301const eyebrowFindings = checkElementHeroEyebrowDOM(el)1302.map(f => ({ type: f.id, detail: f.snippet }))1303.filter(f => _ruleOk(f.type));1304if (eyebrowFindings.length > 0 && el.previousElementSibling) {1305addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings);1306}1307}13081309const pageLevelFindings = [];13101311const typoFindings = checkTypography().filter(f => _ruleOk(f.type));1312if (typoFindings.length > 0) {1313pageLevelFindings.push(...typoFindings);1314addBrowserFindings(groupMap, document.body, typoFindings);1315}13161317const sectionKickerFindings = checkRepeatedSectionKickersDOM()1318.map(f => ({ type: f.id, detail: f.snippet }))1319.filter(f => _ruleOk(f.type));1320if (sectionKickerFindings.length > 0) {1321pageLevelFindings.push(...sectionKickerFindings);1322addBrowserFindings(groupMap, document.body, sectionKickerFindings);1323}13241325const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));1326for (const f of layoutFindings) {1327const el = f.el || document.body;1328addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);1329}13301331// Page-level quality checks (headings, etc.)1332const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));1333if (qualityFindings.length > 0) {1334pageLevelFindings.push(...qualityFindings);1335addBrowserFindings(groupMap, document.body, qualityFindings);1336}13371338const creamFindings = checkCreamPalette(document)1339.map(f => ({ type: f.id, detail: f.snippet }))1340.filter(f => _ruleOk(f.type));1341if (creamFindings.length > 0) {1342pageLevelFindings.push(...creamFindings);1343addBrowserFindings(groupMap, document.body, creamFindings);1344}13451346// Regex-on-HTML checks (shared with Node)1347// Clone the document and strip impeccable-live overlay nodes before the1348// regex scan, so the inspector's own inline styles (transitions on top/1349// left/width/height, etc.) don't register as page anti-patterns.1350const docClone = document.documentElement.cloneNode(true);1351for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {1352node.remove();1353}1354const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);1355if (htmlPatternFindings.length > 0) {1356const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));1357pageLevelFindings.push(...mapped);1358addBrowserFindings(groupMap, document.body, mapped);1359}13601361return {1362groupMap,1363allFindings: browserFindingsFromMap(groupMap),1364pageLevelFindings,1365};1366}13671368function shouldRunVisualContrast(options = {}) {1369return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;1370}13711372function visualContrastOptions(options = {}) {1373const config = window.__IMPECCABLE_CONFIG__ || {};1374const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'1375? options.scrollOffscreen1376: typeof options.visualContrastScrollOffscreen === 'boolean'1377? options.visualContrastScrollOffscreen1378: typeof config.visualContrastScrollOffscreen === 'boolean'1379? config.visualContrastScrollOffscreen1380: false;1381return {1382...options,1383maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)1384? options.visualContrastMaxCandidates1385: Number.isFinite(options.maxCandidates)1386? options.maxCandidates1387: Number.isFinite(config.visualContrastMaxCandidates)1388? config.visualContrastMaxCandidates1389: undefined,1390scrollOffscreen,1391};1392}13931394let lastVisualContrastAnalyses = [];1395let lazyVisualContrastObserver = null;1396let lazyVisualContrastPending = new WeakMap();1397const lazyVisualContrastResolving = new WeakSet();1398let scanGeneration = 0;13991400function rememberVisualContrastAnalysis(result) {1401if (!result?.selector) {1402lastVisualContrastAnalyses.push(result);1403return;1404}1405const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);1406if (idx >= 0) lastVisualContrastAnalyses[idx] = result;1407else lastVisualContrastAnalyses.push(result);1408}14091410function disconnectLazyVisualContrastObserver() {1411if (lazyVisualContrastObserver) {1412lazyVisualContrastObserver.disconnect();1413lazyVisualContrastObserver = null;1414}1415lazyVisualContrastPending = new WeakMap();1416}14171418function addVisualContrastResult(groupMap, result, options = {}) {1419if (result.status !== 'fail' || !result.finding || !result.selector) return false;1420let el = null;1421try {1422el = document.querySelector(result.selector);1423} catch {1424el = null;1425}1426if (!el) return false;1427const findingType = result.finding.type || result.finding.id || 'low-contrast';1428const existing = groupMap.get(el) || [];1429if (existing.some(f => (f.type || f.id) === findingType)) return false;1430addBrowserFindings(groupMap, el, [{1431type: findingType,1432detail: result.finding.detail || result.finding.snippet,1433}]);1434if (options.decorate && el !== document.body && el !== document.documentElement) {1435highlight(el, groupMap.get(el) || []);1436}1437return true;1438}14391440function scanResultMeta(options = {}) {1441const scanId = options.scanId;1442if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};1443return { scanId: String(scanId) };1444}14451446function postSerializedFindings(groupMap, options = {}) {1447if (!EXTENSION_MODE) return;1448const allFindings = browserFindingsFromMap(groupMap);1449window.postMessage({1450source: 'impeccable-results',1451findings: serializeFindings(allFindings),1452count: allFindings.length,1453...scanResultMeta(options),1454}, '*');1455}14561457function postExtensionError(err) {1458if (!EXTENSION_MODE) return;1459window.postMessage({1460source: 'impeccable-error',1461message: err?.message || String(err),1462}, '*');1463}14641465function reportVisualContrastError(err, detail = {}) {1466window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {1467detail: {1468...detail,1469message: err?.message || String(err),1470},1471}));1472if (EXTENSION_MODE) {1473postExtensionError(err);1474} else {1475console.warn('[impeccable] visual contrast scan failed', err);1476}1477}14781479function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {1480disconnectLazyVisualContrastObserver();1481if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;1482if (typeof IntersectionObserver === 'undefined') return;1483const unresolved = (analyses || []).filter(result =>1484result?.status === 'unresolved' &&1485result.reason === 'text outside viewport' &&1486result.selector1487);1488if (unresolved.length === 0) return;1489const generation = runtime.generation || scanGeneration;14901491lazyVisualContrastObserver = new IntersectionObserver((entries) => {1492for (const entry of entries) {1493if (!entry.isIntersecting) continue;1494const el = entry.target;1495const candidate = lazyVisualContrastPending.get(el);1496if (!candidate || lazyVisualContrastResolving.has(el)) continue;1497lazyVisualContrastObserver?.unobserve(el);1498lazyVisualContrastPending.delete(el);1499lazyVisualContrastResolving.add(el);1500waitForVisualPaint()1501.then(() => analyzeVisualContrastCandidate(candidate))1502.then(result => {1503if (generation !== scanGeneration) return;1504rememberVisualContrastAnalysis(result);1505const added = addVisualContrastResult(groupMap, result, { decorate: true });1506if (added) {1507postSerializedFindings(groupMap, options);1508window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {1509detail: {1510selector: result.selector,1511status: result.status,1512finding: result.finding || null,1513},1514}));1515}1516})1517.catch(err => {1518reportVisualContrastError(err, { selector: candidate.selector });1519})1520.finally(() => {1521lazyVisualContrastResolving.delete(el);1522});1523}1524}, { threshold: 0.5 });15251526for (const candidate of unresolved) {1527let el = null;1528try {1529el = document.querySelector(candidate.selector);1530} catch {1531el = null;1532}1533if (!el) continue;1534lazyVisualContrastPending.set(el, candidate);1535lazyVisualContrastObserver.observe(el);1536}1537}15381539async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {1540if (!shouldRunVisualContrast(options)) {1541lastVisualContrastAnalyses = [];1542disconnectLazyVisualContrastObserver();1543return [];1544}1545const resolvedOptions = visualContrastOptions(options);1546const analyses = await analyzeVisualContrast(resolvedOptions);1547if (runtime.generation && runtime.generation !== scanGeneration) return analyses;1548lastVisualContrastAnalyses = analyses;1549for (const result of analyses) {1550addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });1551}1552if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);1553return analyses;1554}15551556async function collectBrowserFindingsAsync(options = {}, runtime = {}) {1557const collected = collectBrowserFindings();1558await addVisualContrastFindings(collected.groupMap, options, runtime);1559return {1560...collected,1561allFindings: browserFindingsFromMap(collected.groupMap),1562visualContrastAnalyses: lastVisualContrastAnalyses,1563};1564}15651566function clearOverlays() {1567scanGeneration += 1;1568disconnectLazyVisualContrastObserver();1569for (const o of [...overlays]) detachOverlay(o);1570overlays.length = 0;1571visibilityObserver.disconnect();1572overlayIndex = 0;1573}15741575function renderBrowserFindings(collected, options = {}) {1576const { allFindings, pageLevelFindings } = collected;15771578for (const { el, findings } of allFindings) {1579if (el === document.body || el === document.documentElement) continue;1580highlight(el, findings);1581}15821583if (pageLevelFindings.length > 0) {1584showPageBanner(pageLevelFindings);1585}15861587if (!EXTENSION_MODE) printSummary(allFindings);15881589// In extension mode, post serialized results for the DevTools panel1590if (EXTENSION_MODE) {1591window.postMessage({1592source: 'impeccable-results',1593findings: serializeFindings(allFindings),1594count: allFindings.length,1595...scanResultMeta(options),1596}, '*');1597}15981599// After this scan completes, all subsequent reveals are instant (no stagger, no animation)1600setTimeout(() => { firstScanDone = true; }, 1000);16011602return allFindings;1603}16041605let firstScanDone = false;1606const scan = function(options = {}) {1607clearOverlays();1608const generation = scanGeneration;1609const collected = collectBrowserFindings();1610const allFindings = renderBrowserFindings(collected, options);1611if (shouldRunVisualContrast(options)) {1612addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })1613.then(() => {1614if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);1615})1616.catch(err => {1617reportVisualContrastError(err);1618});1619}1620return allFindings;1621};16221623const scanAsync = async function(options = {}) {1624clearOverlays();1625const generation = scanGeneration;1626if (shouldRunVisualContrast(options)) {1627const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });1628if (generation !== scanGeneration) return [];1629return renderBrowserFindings(collected, options);1630}1631lastVisualContrastAnalyses = [];1632return renderBrowserFindings(collectBrowserFindings(), options);1633};16341635const detect = function(options = {}) {1636lastVisualContrastAnalyses = [];1637const { allFindings } = collectBrowserFindings();1638return options.serialize === false ? allFindings : serializeFindings(allFindings);1639};16401641const detectAsync = async function(options = {}) {1642if (shouldRunVisualContrast(options)) {1643const { allFindings } = await collectBrowserFindingsAsync(options);1644return options.serialize === false ? allFindings : serializeFindings(allFindings);1645}1646lastVisualContrastAnalyses = [];1647const { allFindings } = collectBrowserFindings();1648return options.serialize === false ? allFindings : serializeFindings(allFindings);1649};16501651if (EXTENSION_MODE) {1652// Extension mode: listen for commands, don't auto-scan1653window.addEventListener('message', (e) => {1654if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;1655if (e.data.action === 'scan') {1656if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;1657try {1658scan(e.data.config || {});1659} catch (err) {1660postExtensionError(err);1661}1662}1663if (e.data.action === 'toggle-overlays') {1664const visible = !document.body.classList.contains('impeccable-hidden');1665document.body.classList.toggle('impeccable-hidden', visible);1666window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');1667}1668if (e.data.action === 'remove') {1669clearOverlays();1670styleEl.remove();1671if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }1672document.body.classList.remove('impeccable-hidden');1673}1674if (e.data.action === 'highlight') {1675try {1676const target = e.data.selector ? document.querySelector(e.data.selector) : null;1677if (target) {1678// Scroll first so positionOverlay reads the post-scroll rect1679if (!isInViewport(target) && target.scrollIntoView) {1680target.scrollIntoView({ behavior: 'instant', block: 'center' });1681}1682for (const o of overlays) {1683if (o.classList.contains('impeccable-banner')) continue;1684const isMatch = o._targetEl === target;1685o.classList.toggle('impeccable-spotlight', isMatch);1686o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);1687if (isMatch) {1688// Force the matching overlay visible immediately, don't wait for IntersectionObserver1689o.style.display = '';1690o.style.animation = 'none';1691o.classList.add('impeccable-visible');1692o._revealed = true;1693positionOverlay(o);1694}1695}1696showSpotlight(target);1697}1698} catch { /* invalid selector */ }1699}1700if (e.data.action === 'unhighlight') {1701hideSpotlight();1702for (const o of overlays) {1703o.classList.remove('impeccable-spotlight');1704o.classList.remove('impeccable-spotlight-dimmed');1705}1706}1707});1708window.postMessage({ source: 'impeccable-ready' }, '*');1709} else {1710if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {1711const runAutoScan = () => {1712try {1713scan();1714} catch (err) {1715console.warn('[impeccable] scan failed', err);1716}1717};1718if (document.readyState === 'loading') {1719document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));1720} else {1721setTimeout(runAutoScan, 100);1722}1723}1724}17251726window.impeccableDetect = detect;1727window.impeccableDetectAsync = detectAsync;1728window.impeccableScan = scan;1729window.impeccableScanAsync = scanAsync;1730window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;1731window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;1732window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();1733}1734