| 1 | const IS_BROWSER = typeof window !== 'undefined'; |
| 2 | |
| 3 | // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── |
| 4 | |
| 5 | if (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). |
| 8 | const _myScript = document.currentScript; |
| 9 | const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') |
| 10 | || document.documentElement.dataset.impeccableExtension === 'true'; |
| 11 | |
| 12 | // Kinpaku gold — pinned to the site's brand token (see |
| 13 | // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with |
| 14 | // the picker's C.brand in skill/scripts/live-browser.js and the kit's |
| 15 | // picker section in site/styles/kinpaku-kit.css. |
| 16 | // |
| 17 | // One color across both light and dark host pages. The outline is a |
| 18 | // 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. The |
| 20 | // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 |
| 21 | // against the leaf gold, so reading the rule name is solid in both |
| 22 | // modes. Hover deepens the gold (preserves chroma — never drops it, |
| 23 | // dropping chroma washes the gold into a sand/olive tone). |
| 24 | const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; |
| 25 | const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; |
| 26 | const LABEL_INK = 'oklch(4% 0.004 95)'; |
| 27 | const LABEL_BG = BRAND_COLOR; |
| 28 | const OUTLINE_COLOR = BRAND_COLOR; |
| 29 | |
| 30 | // Inject hover styles via CSS (more reliable than JS event listeners) |
| 31 | const styleEl = document.createElement('style'); |
| 32 | styleEl.textContent = ` |
| 33 | @keyframes impeccable-reveal { |
| 34 | from { opacity: 0; } |
| 35 | to { opacity: 1; } |
| 36 | } |
| 37 | .impeccable-overlay:not(.impeccable-banner) { |
| 38 | pointer-events: none; |
| 39 | outline: 2px solid ${OUTLINE_COLOR}; |
| 40 | border-radius: 4px; |
| 41 | transition: outline-color 0.15s ease; |
| 42 | animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; |
| 43 | animation-play-state: paused; |
| 44 | border-top-left-radius: 0; |
| 45 | } |
| 46 | .impeccable-overlay.impeccable-visible { |
| 47 | animation-play-state: running; |
| 48 | } |
| 49 | .impeccable-overlay.impeccable-hover { |
| 50 | outline-color: ${BRAND_COLOR_HOVER}; |
| 51 | z-index: 100001 !important; |
| 52 | } |
| 53 | .impeccable-overlay.impeccable-hover .impeccable-label { |
| 54 | background: ${BRAND_COLOR_HOVER}; |
| 55 | } |
| 56 | .impeccable-overlay.impeccable-spotlight { |
| 57 | z-index: 100002 !important; |
| 58 | } |
| 59 | .impeccable-overlay.impeccable-spotlight-dimmed { |
| 60 | opacity: 0.15 !important; |
| 61 | animation: none !important; |
| 62 | filter: blur(3px); |
| 63 | } |
| 64 | .impeccable-spotlight-backdrop { |
| 65 | position: fixed; |
| 66 | top: 0; left: 0; right: 0; bottom: 0; |
| 67 | backdrop-filter: blur(3px) brightness(0.6); |
| 68 | -webkit-backdrop-filter: blur(3px) brightness(0.6); |
| 69 | pointer-events: none; |
| 70 | z-index: 99998; |
| 71 | opacity: 0; |
| 72 | outline: none !important; |
| 73 | animation: none !important; |
| 74 | } |
| 75 | .impeccable-spotlight-backdrop.impeccable-visible { |
| 76 | opacity: 1; |
| 77 | } |
| 78 | .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { |
| 79 | display: none !important; |
| 80 | } |
| 81 | `; |
| 82 | (document.head || document.documentElement).appendChild(styleEl); |
| 83 | |
| 84 | // Spotlight backdrop element (created lazily on first use) |
| 85 | let spotlightBackdrop = null; |
| 86 | let spotlightTarget = null; |
| 87 | |
| 88 | function getSpotlightBackdrop() { |
| 89 | if (!spotlightBackdrop) { |
| 90 | spotlightBackdrop = document.createElement('div'); |
| 91 | spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; |
| 92 | document.body.appendChild(spotlightBackdrop); |
| 93 | } |
| 94 | return spotlightBackdrop; |
| 95 | } |
| 96 | |
| 97 | function updateSpotlightClipPath() { |
| 98 | if (!spotlightBackdrop || !spotlightTarget) return; |
| 99 | const r = spotlightTarget.getBoundingClientRect(); |
| 100 | // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) |
| 101 | const inset = 4; |
| 102 | const radius = 6; // outline border-radius (4) + outline width (2) |
| 103 | const x1 = r.left - inset; |
| 104 | const y1 = r.top - inset; |
| 105 | const x2 = r.right + inset; |
| 106 | const y2 = r.bottom + inset; |
| 107 | const vw = window.innerWidth; |
| 108 | const vh = window.innerHeight; |
| 109 | // Outer rect + rounded inner rect (evenodd creates a hole) |
| 110 | const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; |
| 111 | spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; |
| 112 | } |
| 113 | |
| 114 | function showSpotlight(target) { |
| 115 | if (!target || !target.getBoundingClientRect) return; |
| 116 | // Respect the spotlightBlur setting: if disabled, don't show the backdrop |
| 117 | if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { |
| 118 | spotlightTarget = target; |
| 119 | return; |
| 120 | } |
| 121 | spotlightTarget = target; |
| 122 | const bd = getSpotlightBackdrop(); |
| 123 | updateSpotlightClipPath(); |
| 124 | bd.classList.add('impeccable-visible'); |
| 125 | } |
| 126 | |
| 127 | function hideSpotlight() { |
| 128 | spotlightTarget = null; |
| 129 | if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); |
| 130 | } |
| 131 | |
| 132 | function isInViewport(el) { |
| 133 | const r = el.getBoundingClientRect(); |
| 134 | return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; |
| 135 | } |
| 136 | |
| 137 | // Reposition spotlight on scroll/resize |
| 138 | window.addEventListener('scroll', () => { |
| 139 | if (spotlightTarget) updateSpotlightClipPath(); |
| 140 | }, { passive: true }); |
| 141 | window.addEventListener('resize', () => { |
| 142 | if (spotlightTarget) updateSpotlightClipPath(); |
| 143 | }); |
| 144 | |
| 145 | const overlays = []; |
| 146 | const TYPE_LABELS = {}; |
| 147 | const RULE_CATEGORY = {}; |
| 148 | for (const ap of ANTIPATTERNS) { |
| 149 | TYPE_LABELS[ap.id] = ap.name.toLowerCase(); |
| 150 | RULE_CATEGORY[ap.id] = ap.category || 'quality'; |
| 151 | } |
| 152 | |
| 153 | function isInFixedContext(el) { |
| 154 | let p = el; |
| 155 | while (p && p !== document.body) { |
| 156 | if (getComputedStyle(p).position === 'fixed') return true; |
| 157 | p = p.parentElement; |
| 158 | } |
| 159 | return false; |
| 160 | } |
| 161 | |
| 162 | function positionOverlay(overlay) { |
| 163 | const el = overlay._targetEl; |
| 164 | if (!el) return; |
| 165 | const rect = el.getBoundingClientRect(); |
| 166 | if (overlay._isFixed) { |
| 167 | // Viewport-relative coords for fixed targets |
| 168 | overlay.style.top = `${rect.top - 2}px`; |
| 169 | overlay.style.left = `${rect.left - 2}px`; |
| 170 | } else { |
| 171 | // Document-relative coords for normal targets |
| 172 | overlay.style.top = `${rect.top + scrollY - 2}px`; |
| 173 | overlay.style.left = `${rect.left + scrollX - 2}px`; |
| 174 | } |
| 175 | overlay.style.width = `${rect.width + 4}px`; |
| 176 | overlay.style.height = `${rect.height + 4}px`; |
| 177 | } |
| 178 | |
| 179 | function repositionOverlays() { |
| 180 | for (const o of overlays) { |
| 181 | if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; |
| 182 | // Skip overlays whose target is currently hidden (display: none on the overlay) |
| 183 | if (o.style.display === 'none') continue; |
| 184 | positionOverlay(o); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | let resizeRAF; |
| 189 | const onResize = () => { |
| 190 | cancelAnimationFrame(resizeRAF); |
| 191 | resizeRAF = requestAnimationFrame(repositionOverlays); |
| 192 | }; |
| 193 | window.addEventListener('resize', onResize); |
| 194 | // Reposition on scroll too -- catches sticky/parallax shifts |
| 195 | window.addEventListener('scroll', onResize, { passive: true }); |
| 196 | // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) |
| 197 | if (typeof ResizeObserver !== 'undefined') { |
| 198 | const bodyResizeObserver = new ResizeObserver(onResize); |
| 199 | bodyResizeObserver.observe(document.body); |
| 200 | } |
| 201 | |
| 202 | // 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. |
| 206 | let overlayIndex = 0; |
| 207 | const visibilityObserver = new IntersectionObserver((entries) => { |
| 208 | for (const entry of entries) { |
| 209 | const overlay = entry.target._impeccableOverlay; |
| 210 | if (!overlay) continue; |
| 211 | if (entry.isIntersecting) { |
| 212 | overlay.style.display = ''; |
| 213 | positionOverlay(overlay); |
| 214 | if (!overlay._revealed) { |
| 215 | overlay._revealed = true; |
| 216 | if (firstScanDone) { |
| 217 | // Subsequent reveals (re-scans, scroll-into-view): instant, no animation |
| 218 | overlay.style.animation = 'none'; |
| 219 | } else { |
| 220 | // Initial scan: staggered cascade reveal |
| 221 | overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; |
| 222 | } |
| 223 | requestAnimationFrame(() => { |
| 224 | overlay.classList.add('impeccable-visible'); |
| 225 | if (overlay._checkLabel) overlay._checkLabel(); |
| 226 | }); |
| 227 | } |
| 228 | } else { |
| 229 | overlay.style.display = 'none'; |
| 230 | } |
| 231 | } |
| 232 | }, { rootMargin: '99999px' }); |
| 233 | |
| 234 | function detachOverlay(overlay) { |
| 235 | if (!overlay) return; |
| 236 | if (typeof overlay._cleanup === 'function') { |
| 237 | try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } |
| 238 | } |
| 239 | if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { |
| 240 | visibilityObserver.unobserve(overlay._targetEl); |
| 241 | delete overlay._targetEl._impeccableOverlay; |
| 242 | } |
| 243 | const idx = overlays.indexOf(overlay); |
| 244 | if (idx >= 0) overlays.splice(idx, 1); |
| 245 | overlay.remove(); |
| 246 | } |
| 247 | |
| 248 | // Reposition overlays after CSS transitions end (e.g. reveal animations). |
| 249 | // Listens at document level so it catches transitions on ancestor elements |
| 250 | // (the transform may be on a parent, not the flagged element itself). |
| 251 | document.addEventListener('transitionend', (e) => { |
| 252 | if (e.propertyName !== 'transform') return; |
| 253 | for (const o of overlays) { |
| 254 | if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; |
| 255 | if (e.target === o._targetEl || e.target.contains(o._targetEl)) { |
| 256 | positionOverlay(o); |
| 257 | } |
| 258 | } |
| 259 | }); |
| 260 | |
| 261 | const highlight = function(el, findings) { |
| 262 | if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); |
| 263 | const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); |
| 264 | |
| 265 | const fixed = isInFixedContext(el); |
| 266 | const rect = el.getBoundingClientRect(); |
| 267 | const outline = document.createElement('div'); |
| 268 | outline.className = 'impeccable-overlay'; |
| 269 | outline._targetEl = el; |
| 270 | outline._isFixed = fixed; |
| 271 | Object.assign(outline.style, { |
| 272 | position: fixed ? 'fixed' : 'absolute', |
| 273 | top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, |
| 274 | left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, |
| 275 | width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, |
| 276 | zIndex: '99999', boxSizing: 'border-box', |
| 277 | }); |
| 278 | |
| 279 | // Build per-finding label entries: ✦ prefix for slop |
| 280 | const entries = findings.map(f => { |
| 281 | const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; |
| 282 | const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; |
| 283 | return { name: prefix + name, detail: f.detail || f.snippet }; |
| 284 | }); |
| 285 | const allText = entries.map(e => e.name).join(', '); |
| 286 | |
| 287 | const label = document.createElement('div'); |
| 288 | label.className = 'impeccable-label'; |
| 289 | Object.assign(label.style, { |
| 290 | position: 'absolute', bottom: '100%', left: '-2px', |
| 291 | display: 'flex', alignItems: 'center', |
| 292 | whiteSpace: 'nowrap', |
| 293 | fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', |
| 294 | color: LABEL_INK, lineHeight: '14px', |
| 295 | background: LABEL_BG, |
| 296 | fontFamily: 'system-ui, sans-serif', |
| 297 | borderRadius: '4px 4px 0 0', |
| 298 | }); |
| 299 | |
| 300 | const textSpan = document.createElement('span'); |
| 301 | textSpan.style.padding = '3px 8px'; |
| 302 | textSpan.textContent = allText; |
| 303 | label.appendChild(textSpan); |
| 304 | |
| 305 | // State for cycling mode |
| 306 | let cycleMode = false; |
| 307 | let cycleIndex = 0; |
| 308 | let isHovered = false; |
| 309 | let prevBtn, nextBtn; |
| 310 | |
| 311 | function updateCycleText() { |
| 312 | const e = entries[cycleIndex]; |
| 313 | textSpan.textContent = isHovered ? e.detail : e.name; |
| 314 | } |
| 315 | |
| 316 | function enableCycleMode() { |
| 317 | if (cycleMode || entries.length < 2) return; |
| 318 | cycleMode = true; |
| 319 | |
| 320 | const btnStyle = { |
| 321 | background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', |
| 322 | fontSize: '11px', cursor: 'pointer', padding: '3px 4px', |
| 323 | fontFamily: 'system-ui, sans-serif', lineHeight: '14px', |
| 324 | pointerEvents: 'auto', |
| 325 | }; |
| 326 | |
| 327 | const navGroup = document.createElement('span'); |
| 328 | Object.assign(navGroup.style, { |
| 329 | display: 'inline-flex', alignItems: 'center', flexShrink: '0', |
| 330 | }); |
| 331 | |
| 332 | prevBtn = document.createElement('button'); |
| 333 | prevBtn.textContent = '\u2039'; |
| 334 | Object.assign(prevBtn.style, btnStyle); |
| 335 | prevBtn.style.paddingLeft = '6px'; |
| 336 | prevBtn.addEventListener('click', (e) => { |
| 337 | e.stopPropagation(); |
| 338 | cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; |
| 339 | updateCycleText(); |
| 340 | }); |
| 341 | |
| 342 | nextBtn = document.createElement('button'); |
| 343 | nextBtn.textContent = '\u203A'; |
| 344 | Object.assign(nextBtn.style, btnStyle); |
| 345 | nextBtn.style.paddingRight = '2px'; |
| 346 | nextBtn.addEventListener('click', (e) => { |
| 347 | e.stopPropagation(); |
| 348 | cycleIndex = (cycleIndex + 1) % entries.length; |
| 349 | updateCycleText(); |
| 350 | }); |
| 351 | |
| 352 | navGroup.appendChild(prevBtn); |
| 353 | navGroup.appendChild(nextBtn); |
| 354 | label.insertBefore(navGroup, textSpan); |
| 355 | textSpan.style.padding = '3px 8px 3px 4px'; |
| 356 | updateCycleText(); |
| 357 | } |
| 358 | |
| 359 | outline.appendChild(label); |
| 360 | |
| 361 | // Start hidden; the IntersectionObserver will show it once the target is rendered |
| 362 | outline.style.display = 'none'; |
| 363 | outline._staggerIndex = overlayIndex++; |
| 364 | el._impeccableOverlay = outline; |
| 365 | visibilityObserver.observe(el); |
| 366 | |
| 367 | // After first paint, check label width vs outline |
| 368 | outline._checkLabel = () => { |
| 369 | if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { |
| 370 | enableCycleMode(); |
| 371 | } |
| 372 | }; |
| 373 | |
| 374 | // Hover: show detail text, darken |
| 375 | const onMouseEnter = () => { |
| 376 | isHovered = true; |
| 377 | outline.classList.add('impeccable-hover'); |
| 378 | outline.style.outlineColor = BRAND_COLOR_HOVER; |
| 379 | label.style.background = BRAND_COLOR_HOVER; |
| 380 | if (cycleMode) { |
| 381 | updateCycleText(); |
| 382 | } else { |
| 383 | textSpan.textContent = entries.map(e => e.detail).join(' | '); |
| 384 | } |
| 385 | }; |
| 386 | const onMouseLeave = () => { |
| 387 | isHovered = false; |
| 388 | outline.classList.remove('impeccable-hover'); |
| 389 | outline.style.outlineColor = ''; |
| 390 | label.style.background = LABEL_BG; |
| 391 | if (cycleMode) { |
| 392 | updateCycleText(); |
| 393 | } else { |
| 394 | textSpan.textContent = allText; |
| 395 | } |
| 396 | }; |
| 397 | el.addEventListener('mouseenter', onMouseEnter); |
| 398 | el.addEventListener('mouseleave', onMouseLeave); |
| 399 | outline._cleanup = () => { |
| 400 | el.removeEventListener('mouseenter', onMouseEnter); |
| 401 | el.removeEventListener('mouseleave', onMouseLeave); |
| 402 | }; |
| 403 | |
| 404 | document.body.appendChild(outline); |
| 405 | overlays.push(outline); |
| 406 | }; |
| 407 | |
| 408 | const showPageBanner = function(findings) { |
| 409 | if (!findings.length) return; |
| 410 | const banner = document.createElement('div'); |
| 411 | banner.className = 'impeccable-overlay impeccable-banner'; |
| 412 | Object.assign(banner.style, { |
| 413 | position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', |
| 414 | background: LABEL_BG, color: LABEL_INK, |
| 415 | fontFamily: 'system-ui, sans-serif', fontSize: '13px', |
| 416 | display: 'flex', alignItems: 'center', pointerEvents: 'auto', |
| 417 | height: '36px', overflow: 'hidden', maxWidth: '100vw', |
| 418 | transform: 'translateY(-100%)', |
| 419 | transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', |
| 420 | }); |
| 421 | requestAnimationFrame(() => requestAnimationFrame(() => { |
| 422 | banner.style.transform = 'translateY(0)'; |
| 423 | })); |
| 424 | |
| 425 | // Scrollable findings area |
| 426 | const scrollArea = document.createElement('div'); |
| 427 | Object.assign(scrollArea.style, { |
| 428 | flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', |
| 429 | display: 'flex', gap: '8px', alignItems: 'center', |
| 430 | padding: '0 12px', scrollSnapType: 'x mandatory', |
| 431 | scrollbarWidth: 'none', |
| 432 | }); |
| 433 | for (const f of findings) { |
| 434 | const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; |
| 435 | const tag = document.createElement('span'); |
| 436 | tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; |
| 437 | Object.assign(tag.style, { |
| 438 | background: 'rgba(255,255,255,0.15)', padding: '2px 8px', |
| 439 | borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', |
| 440 | whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', |
| 441 | }); |
| 442 | scrollArea.appendChild(tag); |
| 443 | } |
| 444 | banner.appendChild(scrollArea); |
| 445 | |
| 446 | // Controls area (only in standalone mode, not extension) |
| 447 | if (!EXTENSION_MODE) { |
| 448 | const controls = document.createElement('div'); |
| 449 | Object.assign(controls.style, { |
| 450 | display: 'flex', alignItems: 'center', gap: '2px', |
| 451 | padding: '0 8px', flexShrink: '0', |
| 452 | }); |
| 453 | |
| 454 | // Toggle visibility button |
| 455 | const toggle = document.createElement('button'); |
| 456 | toggle.textContent = '\u25C9'; // circle with dot (visible state) |
| 457 | toggle.title = 'Toggle overlay visibility'; |
| 458 | Object.assign(toggle.style, { |
| 459 | background: 'none', border: 'none', |
| 460 | color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', |
| 461 | opacity: '0.85', transition: 'opacity 0.15s', |
| 462 | }); |
| 463 | let overlaysVisible = true; |
| 464 | toggle.addEventListener('click', () => { |
| 465 | overlaysVisible = !overlaysVisible; |
| 466 | document.body.classList.toggle('impeccable-hidden', !overlaysVisible); |
| 467 | toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle |
| 468 | toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; |
| 469 | }); |
| 470 | controls.appendChild(toggle); |
| 471 | |
| 472 | // Close button |
| 473 | const close = document.createElement('button'); |
| 474 | close.textContent = '\u00d7'; |
| 475 | close.title = 'Dismiss banner'; |
| 476 | Object.assign(close.style, { |
| 477 | background: 'none', border: 'none', |
| 478 | color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', |
| 479 | }); |
| 480 | close.addEventListener('click', () => banner.remove()); |
| 481 | controls.appendChild(close); |
| 482 | |
| 483 | banner.appendChild(controls); |
| 484 | } |
| 485 | document.body.appendChild(banner); |
| 486 | overlays.push(banner); |
| 487 | }; |
| 488 | |
| 489 | // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". |
| 490 | // These change between builds and produce brittle, ugly selectors. |
| 491 | function isLikelyHashedClass(c) { |
| 492 | if (!c) return true; |
| 493 | if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; |
| 494 | if (/^_[\w-]{5,}$/.test(c)) return true; |
| 495 | if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; |
| 496 | return false; |
| 497 | } |
| 498 | |
| 499 | function buildSelectorSegment(el) { |
| 500 | const tag = el.tagName.toLowerCase(); |
| 501 | let sel = tag; |
| 502 | |
| 503 | if (el.classList && el.classList.length > 0) { |
| 504 | const classes = [...el.classList] |
| 505 | .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) |
| 506 | .slice(0, 2); |
| 507 | if (classes.length > 0) { |
| 508 | sel += '.' + classes.map(c => CSS.escape(c)).join('.'); |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // Disambiguate among siblings only if the parent has multiple matches |
| 513 | const parent = el.parentElement; |
| 514 | if (parent) { |
| 515 | try { |
| 516 | const matching = parent.querySelectorAll(':scope > ' + sel); |
| 517 | if (matching.length > 1) { |
| 518 | const sameType = [...parent.children].filter(c => c.tagName === el.tagName); |
| 519 | const idx = sameType.indexOf(el) + 1; |
| 520 | sel += `:nth-of-type(${idx})`; |
| 521 | } |
| 522 | } catch { |
| 523 | const idx = [...parent.children].indexOf(el) + 1; |
| 524 | sel = `${tag}:nth-child(${idx})`; |
| 525 | } |
| 526 | } |
| 527 | return sel; |
| 528 | } |
| 529 | |
| 530 | function generateSelector(el) { |
| 531 | if (el === document.body) return 'body'; |
| 532 | if (el === document.documentElement) return 'html'; |
| 533 | if (el.id) return '#' + CSS.escape(el.id); |
| 534 | |
| 535 | const parts = []; |
| 536 | let current = el; |
| 537 | let depth = 0; |
| 538 | const MAX_DEPTH = 10; |
| 539 | |
| 540 | while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { |
| 541 | parts.unshift(buildSelectorSegment(current)); |
| 542 | |
| 543 | // Anchor on an ancestor's ID and stop walking up |
| 544 | if (current.id) { |
| 545 | parts[0] = '#' + CSS.escape(current.id); |
| 546 | break; |
| 547 | } |
| 548 | |
| 549 | // Stop as soon as the partial selector uniquely identifies the target |
| 550 | const trySelector = parts.join(' > '); |
| 551 | try { |
| 552 | const matches = document.querySelectorAll(trySelector); |
| 553 | if (matches.length === 1 && matches[0] === el) { |
| 554 | return trySelector; |
| 555 | } |
| 556 | } catch { /* invalid selector — keep walking */ } |
| 557 | |
| 558 | current = current.parentElement; |
| 559 | depth++; |
| 560 | } |
| 561 | |
| 562 | return parts.join(' > '); |
| 563 | } |
| 564 | |
| 565 | function getDirectText(el) { |
| 566 | return [...el.childNodes] |
| 567 | .filter(n => n.nodeType === 3) |
| 568 | .map(n => n.textContent || '') |
| 569 | .join(''); |
| 570 | } |
| 571 | |
| 572 | function getDirectTextRect(el) { |
| 573 | const rects = []; |
| 574 | for (const node of el.childNodes) { |
| 575 | if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; |
| 576 | const range = document.createRange(); |
| 577 | range.selectNodeContents(node); |
| 578 | for (const rect of range.getClientRects()) { |
| 579 | if (rect.width >= 1 && rect.height >= 1) rects.push(rect); |
| 580 | } |
| 581 | range.detach?.(); |
| 582 | } |
| 583 | if (rects.length === 0) return null; |
| 584 | const left = Math.min(...rects.map(r => r.left)); |
| 585 | const top = Math.min(...rects.map(r => r.top)); |
| 586 | const right = Math.max(...rects.map(r => r.right)); |
| 587 | const bottom = Math.max(...rects.map(r => r.bottom)); |
| 588 | return { |
| 589 | left, |
| 590 | top, |
| 591 | right, |
| 592 | bottom, |
| 593 | width: right - left, |
| 594 | height: bottom - top, |
| 595 | x: left, |
| 596 | y: top, |
| 597 | }; |
| 598 | } |
| 599 | |
| 600 | function collectVisualContrastReasons(el, style) { |
| 601 | const reasons = new Set(); |
| 602 | const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; |
| 603 | const ownBgImage = style.backgroundImage || ''; |
| 604 | if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { |
| 605 | reasons.add('background-clip text'); |
| 606 | } |
| 607 | if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); |
| 608 | |
| 609 | let current = el; |
| 610 | while (current && current.nodeType === 1) { |
| 611 | const tag = current.tagName?.toLowerCase(); |
| 612 | const currentStyle = getComputedStyle(current); |
| 613 | const bgImage = currentStyle.backgroundImage || ''; |
| 614 | const isDocumentSurface = tag === 'body' || tag === 'html'; |
| 615 | |
| 616 | if (!isDocumentSurface && bgImage && bgImage !== 'none') { |
| 617 | if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); |
| 618 | if (/gradient/i.test(bgImage)) reasons.add('gradient background'); |
| 619 | } |
| 620 | if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); |
| 621 | if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); |
| 622 | if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); |
| 623 | if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); |
| 624 | |
| 625 | const solidBg = parseRgb(currentStyle.backgroundColor); |
| 626 | if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; |
| 627 | current = current.parentElement; |
| 628 | } |
| 629 | |
| 630 | const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 631 | if (sampleRect && document.elementsFromPoint) { |
| 632 | const 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 | ]; |
| 637 | for (const [x, y] of points) { |
| 638 | if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; |
| 639 | const stack = document.elementsFromPoint(x, y); |
| 640 | const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); |
| 641 | if (selfIndex < 0) continue; |
| 642 | for (const node of stack.slice(selfIndex + 1)) { |
| 643 | const nodeTag = node.tagName?.toLowerCase(); |
| 644 | if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { |
| 645 | reasons.add(`${nodeTag} underlay`); |
| 646 | break; |
| 647 | } |
| 648 | } |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | return [...reasons]; |
| 653 | } |
| 654 | |
| 655 | function collectVisualContrastCandidates(options = {}) { |
| 656 | const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; |
| 657 | const candidates = []; |
| 658 | for (const el of document.querySelectorAll('*')) { |
| 659 | if (candidates.length >= maxCandidates) break; |
| 660 | if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 661 | if (el.closest('[id^="impeccable-live-"]')) continue; |
| 662 | if (el === document.body || el === document.documentElement) continue; |
| 663 | |
| 664 | const tag = el.tagName.toLowerCase(); |
| 665 | const style = getComputedStyle(el); |
| 666 | if (style.display === 'none' || style.visibility === 'hidden') continue; |
| 667 | const directText = getDirectText(el); |
| 668 | const hasDirectText = directText.trim().length > 0; |
| 669 | if (!hasDirectText || isEmojiOnlyText(directText)) continue; |
| 670 | |
| 671 | const bgColor = readOwnBackgroundColor(el, style); |
| 672 | const isStyledButton = (tag === 'a' || tag === 'button') |
| 673 | && bgColor && bgColor.a > 0.5; |
| 674 | if (SAFE_TAGS.has(tag) && !isStyledButton) continue; |
| 675 | |
| 676 | const rect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 677 | if (!rect || rect.width < 4 || rect.height < 4) continue; |
| 678 | |
| 679 | const reasons = collectVisualContrastReasons(el, style); |
| 680 | if (reasons.length === 0) continue; |
| 681 | |
| 682 | const textColor = parseRgb(style.color); |
| 683 | const fontSize = parseFloat(style.fontSize) || 16; |
| 684 | const fontWeight = parseInt(style.fontWeight) || 400; |
| 685 | const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); |
| 686 | const threshold = isLargeText ? 3.0 : 4.5; |
| 687 | const clip = { |
| 688 | x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), |
| 689 | y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), |
| 690 | width: Math.max(1, Math.ceil(rect.width + 4)), |
| 691 | height: Math.max(1, Math.ceil(rect.height + 4)), |
| 692 | }; |
| 693 | |
| 694 | candidates.push({ |
| 695 | selector: generateSelector(el), |
| 696 | tagName: tag, |
| 697 | text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), |
| 698 | threshold, |
| 699 | reasons, |
| 700 | clip, |
| 701 | textColor, |
| 702 | preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => |
| 703 | reason === 'opacity stack' || |
| 704 | reason === 'blend mode' || |
| 705 | reason === 'filter' || |
| 706 | reason === 'backdrop filter' || |
| 707 | reason === 'background-clip text' |
| 708 | ), |
| 709 | backgroundClipText: reasons.includes('background-clip text'), |
| 710 | }); |
| 711 | } |
| 712 | return candidates; |
| 713 | } |
| 714 | |
| 715 | const visualContrastImageCache = new Map(); |
| 716 | const visualContrastRasterCache = new WeakMap(); |
| 717 | |
| 718 | function clampByte(value) { |
| 719 | return Math.max(0, Math.min(255, Math.round(value))); |
| 720 | } |
| 721 | |
| 722 | function blendRgba(fg, bg) { |
| 723 | if (!fg) return bg || null; |
| 724 | if (!bg || fg.a == null || fg.a >= 0.999) { |
| 725 | return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; |
| 726 | } |
| 727 | const alpha = Math.max(0, Math.min(1, fg.a)); |
| 728 | return { |
| 729 | r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), |
| 730 | g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), |
| 731 | b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), |
| 732 | a: 1, |
| 733 | }; |
| 734 | } |
| 735 | |
| 736 | function pickWorstContrastColor(textColor, colors) { |
| 737 | const usable = (colors || []).filter(Boolean); |
| 738 | if (!usable.length) return null; |
| 739 | let worst = usable[0]; |
| 740 | let worstRatio = contrastRatio(textColor, worst); |
| 741 | for (const color of usable.slice(1)) { |
| 742 | const ratio = contrastRatio(textColor, color); |
| 743 | if (ratio < worstRatio) { |
| 744 | worst = color; |
| 745 | worstRatio = ratio; |
| 746 | } |
| 747 | } |
| 748 | return worst; |
| 749 | } |
| 750 | |
| 751 | function firstCssUrl(value) { |
| 752 | const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); |
| 753 | if (!match) return ''; |
| 754 | return (match[1] || match[2] || match[3] || '').trim(); |
| 755 | } |
| 756 | |
| 757 | function getLayerValue(value, index = 0) { |
| 758 | return String(value || '').split(',')[index]?.trim() || ''; |
| 759 | } |
| 760 | |
| 761 | function parsePositionToken(token, container, painted) { |
| 762 | if (!token || token === 'center') return (container - painted) / 2; |
| 763 | if (token === 'left' || token === 'top') return 0; |
| 764 | if (token === 'right' || token === 'bottom') return container - painted; |
| 765 | if (/%$/.test(token)) { |
| 766 | const pct = parseFloat(token) / 100; |
| 767 | return (container - painted) * pct; |
| 768 | } |
| 769 | if (/px$/.test(token)) return parseFloat(token) || 0; |
| 770 | return (container - painted) / 2; |
| 771 | } |
| 772 | |
| 773 | function parsePositionPair(positionValue) { |
| 774 | const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); |
| 775 | const first = tokens[0] || '50%'; |
| 776 | if (tokens.length < 2) { |
| 777 | if (first === 'top' || first === 'bottom') return ['50%', first]; |
| 778 | return [first, '50%']; |
| 779 | } |
| 780 | return [first, tokens[1] || '50%']; |
| 781 | } |
| 782 | |
| 783 | function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { |
| 784 | const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; |
| 785 | const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; |
| 786 | let paintedWidth = intrinsicWidth; |
| 787 | let paintedHeight = intrinsicHeight; |
| 788 | const size = String(sizeValue || 'auto').trim(); |
| 789 | |
| 790 | if (size === 'cover' || size === 'contain') { |
| 791 | const scale = size === 'cover' |
| 792 | ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) |
| 793 | : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); |
| 794 | paintedWidth = intrinsicWidth * scale; |
| 795 | paintedHeight = intrinsicHeight * scale; |
| 796 | } else if (size && size !== 'auto') { |
| 797 | const parts = size.split(/\s+/); |
| 798 | const widthToken = parts[0]; |
| 799 | const heightToken = parts[1] || 'auto'; |
| 800 | if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); |
| 801 | else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; |
| 802 | if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); |
| 803 | else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); |
| 804 | else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; |
| 805 | } |
| 806 | |
| 807 | const [xToken, yToken] = parsePositionPair(positionValue); |
| 808 | const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); |
| 809 | const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); |
| 810 | return { |
| 811 | left: containerRect.left + positionX, |
| 812 | top: containerRect.top + positionY, |
| 813 | width: paintedWidth, |
| 814 | height: paintedHeight, |
| 815 | intrinsicWidth, |
| 816 | intrinsicHeight, |
| 817 | }; |
| 818 | } |
| 819 | |
| 820 | function parseObjectPosition(positionValue) { |
| 821 | return parsePositionPair(positionValue); |
| 822 | } |
| 823 | |
| 824 | function resolveObjectImageRect(containerRect, image, style) { |
| 825 | const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; |
| 826 | const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; |
| 827 | const fit = style.objectFit || 'fill'; |
| 828 | let paintedWidth = containerRect.width; |
| 829 | let paintedHeight = containerRect.height; |
| 830 | if (fit === 'contain' || fit === 'cover') { |
| 831 | const scale = fit === 'cover' |
| 832 | ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) |
| 833 | : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); |
| 834 | paintedWidth = intrinsicWidth * scale; |
| 835 | paintedHeight = intrinsicHeight * scale; |
| 836 | } else if (fit === 'none') { |
| 837 | paintedWidth = intrinsicWidth; |
| 838 | paintedHeight = intrinsicHeight; |
| 839 | } else if (fit === 'scale-down') { |
| 840 | const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); |
| 841 | paintedWidth = intrinsicWidth * containScale; |
| 842 | paintedHeight = intrinsicHeight * containScale; |
| 843 | } |
| 844 | const [xToken, yToken] = parseObjectPosition(style.objectPosition); |
| 845 | return { |
| 846 | left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), |
| 847 | top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), |
| 848 | width: paintedWidth, |
| 849 | height: paintedHeight, |
| 850 | intrinsicWidth, |
| 851 | intrinsicHeight, |
| 852 | }; |
| 853 | } |
| 854 | |
| 855 | function pointToImageSource(point, paintedRect) { |
| 856 | if ( |
| 857 | point.x < paintedRect.left || |
| 858 | point.y < paintedRect.top || |
| 859 | point.x > paintedRect.left + paintedRect.width || |
| 860 | point.y > paintedRect.top + paintedRect.height |
| 861 | ) { |
| 862 | return null; |
| 863 | } |
| 864 | return { |
| 865 | x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), |
| 866 | y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), |
| 867 | }; |
| 868 | } |
| 869 | |
| 870 | async function loadVisualContrastImage(src) { |
| 871 | if (!src) return null; |
| 872 | if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); |
| 873 | const promise = new Promise(resolve => { |
| 874 | const img = new Image(); |
| 875 | let settled = false; |
| 876 | const finish = value => { |
| 877 | if (settled) return; |
| 878 | settled = true; |
| 879 | clearTimeout(timer); |
| 880 | resolve(value); |
| 881 | }; |
| 882 | const timer = setTimeout(() => finish(null), 800); |
| 883 | try { |
| 884 | const absolute = new URL(src, location.href); |
| 885 | if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { |
| 886 | img.crossOrigin = 'anonymous'; |
| 887 | } |
| 888 | } catch { |
| 889 | // Let the browser resolve unusual URLs itself. |
| 890 | } |
| 891 | img.onload = () => finish(img); |
| 892 | img.onerror = () => finish(null); |
| 893 | img.src = src; |
| 894 | }); |
| 895 | visualContrastImageCache.set(src, promise); |
| 896 | return promise; |
| 897 | } |
| 898 | |
| 899 | function sampleDrawablePixel(drawable, sourcePoint) { |
| 900 | if (visualContrastRasterCache.has(drawable)) { |
| 901 | const cached = visualContrastRasterCache.get(drawable); |
| 902 | if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; |
| 903 | try { |
| 904 | const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); |
| 905 | const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); |
| 906 | const data = cached.ctx.getImageData(x, y, 1, 1).data; |
| 907 | return { |
| 908 | status: 'sampled', |
| 909 | color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, |
| 910 | }; |
| 911 | } catch (err) { |
| 912 | return { |
| 913 | status: 'unresolved', |
| 914 | reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', |
| 915 | }; |
| 916 | } |
| 917 | } |
| 918 | |
| 919 | const canvas = document.createElement('canvas'); |
| 920 | const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; |
| 921 | const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; |
| 922 | const maxRasterSide = 640; |
| 923 | const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); |
| 924 | canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); |
| 925 | canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); |
| 926 | const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| 927 | if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; |
| 928 | try { |
| 929 | ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); |
| 930 | const cached = { |
| 931 | ctx, |
| 932 | width: canvas.width, |
| 933 | height: canvas.height, |
| 934 | scaleX: canvas.width / intrinsicWidth, |
| 935 | scaleY: canvas.height / intrinsicHeight, |
| 936 | }; |
| 937 | visualContrastRasterCache.set(drawable, cached); |
| 938 | const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); |
| 939 | const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); |
| 940 | const data = ctx.getImageData(x, y, 1, 1).data; |
| 941 | return { |
| 942 | status: 'sampled', |
| 943 | color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, |
| 944 | }; |
| 945 | } catch (err) { |
| 946 | const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; |
| 947 | visualContrastRasterCache.set(drawable, { ctx: null, reason }); |
| 948 | return { |
| 949 | status: 'unresolved', |
| 950 | reason, |
| 951 | }; |
| 952 | } |
| 953 | } |
| 954 | |
| 955 | async function sampleCssBackground(el, style, point, textColor) { |
| 956 | const rect = el.getBoundingClientRect(); |
| 957 | const bgImage = style.backgroundImage || ''; |
| 958 | if (bgImage && bgImage !== 'none') { |
| 959 | if (/gradient/i.test(bgImage)) { |
| 960 | const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); |
| 961 | if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; |
| 962 | } |
| 963 | if (/url\s*\(/i.test(bgImage)) { |
| 964 | const img = await loadVisualContrastImage(firstCssUrl(bgImage)); |
| 965 | if (!img) return { status: 'unresolved', reason: 'image unavailable' }; |
| 966 | const paintedRect = resolvePaintedImageRect( |
| 967 | rect, |
| 968 | img, |
| 969 | getLayerValue(style.backgroundSize) || 'auto', |
| 970 | getLayerValue(style.backgroundPosition) || '50% 50%', |
| 971 | ); |
| 972 | const sourcePoint = pointToImageSource(point, paintedRect); |
| 973 | if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; |
| 974 | const sample = sampleDrawablePixel(img, sourcePoint); |
| 975 | if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; |
| 976 | return sample; |
| 977 | } |
| 978 | } |
| 979 | const bg = parseRgb(style.backgroundColor); |
| 980 | if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; |
| 981 | return { status: 'unresolved', reason: 'no readable background' }; |
| 982 | } |
| 983 | |
| 984 | async function sampleImageElement(img, point) { |
| 985 | const rect = img.getBoundingClientRect(); |
| 986 | const style = getComputedStyle(img); |
| 987 | const paintedRect = resolveObjectImageRect(rect, img, style); |
| 988 | const sourcePoint = pointToImageSource(point, paintedRect); |
| 989 | if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; |
| 990 | const sample = sampleDrawablePixel(img, sourcePoint); |
| 991 | if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; |
| 992 | |
| 993 | if (img.currentSrc || img.src) { |
| 994 | const loaded = await loadVisualContrastImage(img.currentSrc || img.src); |
| 995 | if (loaded) { |
| 996 | const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; |
| 997 | const loadedPoint = pointToImageSource(point, loadedRect); |
| 998 | if (loadedPoint) { |
| 999 | const loadedSample = sampleDrawablePixel(loaded, loadedPoint); |
| 1000 | if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; |
| 1001 | } |
| 1002 | } |
| 1003 | } |
| 1004 | return sample; |
| 1005 | } |
| 1006 | |
| 1007 | function textSamplePoints(rect) { |
| 1008 | const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); |
| 1009 | const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); |
| 1010 | const xs = rect.width < 28 |
| 1011 | ? [rect.left + rect.width / 2] |
| 1012 | : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; |
| 1013 | const ys = rect.height < 22 |
| 1014 | ? [rect.top + rect.height / 2] |
| 1015 | : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; |
| 1016 | const points = []; |
| 1017 | for (const y of ys) { |
| 1018 | for (const x of xs) { |
| 1019 | if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); |
| 1020 | } |
| 1021 | } |
| 1022 | return points; |
| 1023 | } |
| 1024 | |
| 1025 | async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { |
| 1026 | if (depth > 8) { |
| 1027 | return { status: 'unresolved', reason: 'background stack too deep' }; |
| 1028 | } |
| 1029 | const stack = typeof document.elementsFromPoint === 'function' |
| 1030 | ? document.elementsFromPoint(point.x, point.y) |
| 1031 | : []; |
| 1032 | const selfIndex = stack.findIndex(node => node === el || el.contains(node)); |
| 1033 | const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; |
| 1034 | const unresolved = []; |
| 1035 | |
| 1036 | for (const node of nodes) { |
| 1037 | if (!node || node.nodeType !== 1) continue; |
| 1038 | if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 1039 | const tag = node.tagName?.toLowerCase(); |
| 1040 | if (tag === 'img') { |
| 1041 | const sample = await sampleImageElement(node, point); |
| 1042 | if (sample.status === 'sampled') return sample; |
| 1043 | unresolved.push(sample.reason); |
| 1044 | continue; |
| 1045 | } |
| 1046 | if (tag === 'canvas' || tag === 'video') { |
| 1047 | const rect = node.getBoundingClientRect(); |
| 1048 | const sourcePoint = pointToImageSource(point, { |
| 1049 | left: rect.left, |
| 1050 | top: rect.top, |
| 1051 | width: rect.width, |
| 1052 | height: rect.height, |
| 1053 | intrinsicWidth: node.width || node.videoWidth || rect.width, |
| 1054 | intrinsicHeight: node.height || node.videoHeight || rect.height, |
| 1055 | }); |
| 1056 | if (sourcePoint) { |
| 1057 | const sample = sampleDrawablePixel(node, sourcePoint); |
| 1058 | if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; |
| 1059 | unresolved.push(sample.reason); |
| 1060 | } |
| 1061 | continue; |
| 1062 | } |
| 1063 | const style = getComputedStyle(node); |
| 1064 | const sample = await sampleCssBackground(node, style, point, textColor); |
| 1065 | if (sample.status === 'sampled') { |
| 1066 | if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; |
| 1067 | const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); |
| 1068 | if (under.status === 'sampled') { |
| 1069 | return { |
| 1070 | status: 'sampled', |
| 1071 | color: blendRgba(sample.color, under.color), |
| 1072 | method: `${sample.method}+alpha`, |
| 1073 | }; |
| 1074 | } |
| 1075 | return sample; |
| 1076 | } |
| 1077 | unresolved.push(sample.reason); |
| 1078 | } |
| 1079 | |
| 1080 | return { |
| 1081 | status: 'unresolved', |
| 1082 | reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', |
| 1083 | }; |
| 1084 | } |
| 1085 | |
| 1086 | async function analyzeVisualContrastCandidate(candidate) { |
| 1087 | let el; |
| 1088 | try { |
| 1089 | el = document.querySelector(candidate.selector); |
| 1090 | } catch { |
| 1091 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; |
| 1092 | } |
| 1093 | if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; |
| 1094 | |
| 1095 | const blockingReason = (candidate.reasons || []).find(reason => |
| 1096 | reason === 'background-clip text' || |
| 1097 | reason === 'blend mode' || |
| 1098 | reason === 'filter' || |
| 1099 | reason === 'backdrop filter' || |
| 1100 | reason === 'opacity stack' || |
| 1101 | reason === 'text shadow' |
| 1102 | ); |
| 1103 | if (blockingReason) { |
| 1104 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; |
| 1105 | } |
| 1106 | |
| 1107 | const style = getComputedStyle(el); |
| 1108 | const textColor = parseRgb(style.color) || candidate.textColor; |
| 1109 | if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; |
| 1110 | |
| 1111 | const rect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 1112 | if (!rect || rect.width < 4 || rect.height < 4) { |
| 1113 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; |
| 1114 | } |
| 1115 | |
| 1116 | const points = textSamplePoints(rect); |
| 1117 | if (points.length === 0) { |
| 1118 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; |
| 1119 | } |
| 1120 | |
| 1121 | const ratios = []; |
| 1122 | const methods = new Set(); |
| 1123 | const unresolved = []; |
| 1124 | for (const point of points) { |
| 1125 | const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); |
| 1126 | if (sample.status !== 'sampled' || !sample.color) { |
| 1127 | unresolved.push(sample.reason); |
| 1128 | continue; |
| 1129 | } |
| 1130 | const fg = blendRgba(textColor, sample.color); |
| 1131 | ratios.push(contrastRatio(fg, sample.color)); |
| 1132 | if (sample.method) methods.add(sample.method); |
| 1133 | } |
| 1134 | |
| 1135 | if (ratios.length < Math.min(3, points.length)) { |
| 1136 | return { |
| 1137 | ...candidate, |
| 1138 | status: 'unresolved', |
| 1139 | confidence: 'none', |
| 1140 | samples: ratios.length, |
| 1141 | reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', |
| 1142 | }; |
| 1143 | } |
| 1144 | |
| 1145 | ratios.sort((a, b) => a - b); |
| 1146 | const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; |
| 1147 | const measuredRatio = pick(10); |
| 1148 | const medianRatio = pick(50); |
| 1149 | const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; |
| 1150 | const method = [...methods].sort().join(', ') || 'browser-visual'; |
| 1151 | const textLabel = candidate.text ? ` "${candidate.text}"` : ''; |
| 1152 | const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; |
| 1153 | return { |
| 1154 | ...candidate, |
| 1155 | status, |
| 1156 | confidence: method.includes('canvas-') ? 'high' : 'medium', |
| 1157 | method, |
| 1158 | ratio: measuredRatio, |
| 1159 | medianRatio, |
| 1160 | samples: ratios.length, |
| 1161 | finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, |
| 1162 | }; |
| 1163 | } |
| 1164 | |
| 1165 | function waitForVisualPaint() { |
| 1166 | return new Promise(resolve => { |
| 1167 | requestAnimationFrame(() => requestAnimationFrame(resolve)); |
| 1168 | }); |
| 1169 | } |
| 1170 | |
| 1171 | async function analyzeVisualContrast(options = {}) { |
| 1172 | const candidates = collectVisualContrastCandidates(options); |
| 1173 | const results = []; |
| 1174 | const shouldScrollOffscreen = options.scrollOffscreen === true; |
| 1175 | const restoreScroll = { x: window.scrollX, y: window.scrollY }; |
| 1176 | for (const candidate of candidates) { |
| 1177 | if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { |
| 1178 | window.scrollTo(restoreScroll.x, restoreScroll.y); |
| 1179 | await waitForVisualPaint(); |
| 1180 | } |
| 1181 | let result = await analyzeVisualContrastCandidate(candidate); |
| 1182 | if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { |
| 1183 | let el = null; |
| 1184 | try { |
| 1185 | el = document.querySelector(candidate.selector); |
| 1186 | } catch { |
| 1187 | el = null; |
| 1188 | } |
| 1189 | if (el && typeof el.scrollIntoView === 'function') { |
| 1190 | el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); |
| 1191 | await waitForVisualPaint(); |
| 1192 | result = await analyzeVisualContrastCandidate(candidate); |
| 1193 | } |
| 1194 | } |
| 1195 | results.push(result); |
| 1196 | } |
| 1197 | if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { |
| 1198 | window.scrollTo(restoreScroll.x, restoreScroll.y); |
| 1199 | } |
| 1200 | return results; |
| 1201 | } |
| 1202 | |
| 1203 | function isElementHidden(el) { |
| 1204 | if (!el || el === document.body || el === document.documentElement) return false; |
| 1205 | if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); |
| 1206 | // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) |
| 1207 | return el.offsetWidth === 0 && el.offsetHeight === 0; |
| 1208 | } |
| 1209 | |
| 1210 | function serializeFindings(allFindings) { |
| 1211 | return allFindings.map(({ el, findings }) => ({ |
| 1212 | selector: generateSelector(el), |
| 1213 | tagName: el.tagName?.toLowerCase() || 'unknown', |
| 1214 | rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) |
| 1215 | ? el.getBoundingClientRect().toJSON() : null, |
| 1216 | isPageLevel: el === document.body || el === document.documentElement, |
| 1217 | isHidden: isElementHidden(el), |
| 1218 | findings: findings.map(f => { |
| 1219 | const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); |
| 1220 | return { |
| 1221 | type: f.type || f.id, |
| 1222 | category: ap ? ap.category : 'quality', |
| 1223 | severity: ap?.severity || 'warning', |
| 1224 | detail: f.detail || f.snippet, |
| 1225 | name: ap ? ap.name : (f.type || f.id), |
| 1226 | description: ap ? ap.description : '', |
| 1227 | }; |
| 1228 | }), |
| 1229 | })); |
| 1230 | } |
| 1231 | |
| 1232 | const printSummary = function(allFindings) { |
| 1233 | if (allFindings.length === 0) { |
| 1234 | console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); |
| 1235 | return; |
| 1236 | } |
| 1237 | console.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 | ); |
| 1241 | for (const { el, findings } of allFindings) { |
| 1242 | for (const f of findings) { |
| 1243 | console.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 | } |
| 1247 | console.groupEnd(); |
| 1248 | }; |
| 1249 | |
| 1250 | function addBrowserFindings(groupMap, el, findings) { |
| 1251 | if (!findings || findings.length === 0) return; |
| 1252 | const existing = groupMap.get(el); |
| 1253 | if (existing) existing.push(...findings); |
| 1254 | else groupMap.set(el, [...findings]); |
| 1255 | } |
| 1256 | |
| 1257 | function browserFindingsFromMap(groupMap) { |
| 1258 | return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); |
| 1259 | } |
| 1260 | |
| 1261 | function collectBrowserFindings() { |
| 1262 | const groupMap = new Map(); |
| 1263 | const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; |
| 1264 | const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); |
| 1265 | // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a |
| 1266 | // real browser env (detector page, live overlay, extension) running every |
| 1267 | // check is free, so we always surface them; the gating is purely a CLI |
| 1268 | // output concern, applied in the Node engines' detect* return paths. |
| 1269 | |
| 1270 | for (const el of document.querySelectorAll('*')) { |
| 1271 | // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) |
| 1272 | if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 1273 | // Skip browser extension elements (Claude, etc.) |
| 1274 | const elId = el.id || ''; |
| 1275 | if (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. |
| 1278 | if (el.closest('[id^="impeccable-live-"]')) continue; |
| 1279 | // Skip html/body -- page-level findings go in the banner, not a full-page overlay |
| 1280 | if (el === document.body || el === document.documentElement) continue; |
| 1281 | |
| 1282 | const 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)); |
| 1296 | |
| 1297 | addBrowserFindings(groupMap, el, findings); |
| 1298 | |
| 1299 | // Hero eyebrow: the offending element is the eyebrow above the heading, |
| 1300 | // not the heading itself — highlight the previous sibling instead. |
| 1301 | const eyebrowFindings = checkElementHeroEyebrowDOM(el) |
| 1302 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 1303 | .filter(f => _ruleOk(f.type)); |
| 1304 | if (eyebrowFindings.length > 0 && el.previousElementSibling) { |
| 1305 | addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); |
| 1306 | } |
| 1307 | } |
| 1308 | |
| 1309 | const pageLevelFindings = []; |
| 1310 | |
| 1311 | const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); |
| 1312 | if (typoFindings.length > 0) { |
| 1313 | pageLevelFindings.push(...typoFindings); |
| 1314 | addBrowserFindings(groupMap, document.body, typoFindings); |
| 1315 | } |
| 1316 | |
| 1317 | const sectionKickerFindings = checkRepeatedSectionKickersDOM() |
| 1318 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 1319 | .filter(f => _ruleOk(f.type)); |
| 1320 | if (sectionKickerFindings.length > 0) { |
| 1321 | pageLevelFindings.push(...sectionKickerFindings); |
| 1322 | addBrowserFindings(groupMap, document.body, sectionKickerFindings); |
| 1323 | } |
| 1324 | |
| 1325 | const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); |
| 1326 | for (const f of layoutFindings) { |
| 1327 | const el = f.el || document.body; |
| 1328 | addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); |
| 1329 | } |
| 1330 | |
| 1331 | // Page-level quality checks (headings, etc.) |
| 1332 | const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); |
| 1333 | if (qualityFindings.length > 0) { |
| 1334 | pageLevelFindings.push(...qualityFindings); |
| 1335 | addBrowserFindings(groupMap, document.body, qualityFindings); |
| 1336 | } |
| 1337 | |
| 1338 | const creamFindings = checkCreamPalette(document) |
| 1339 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 1340 | .filter(f => _ruleOk(f.type)); |
| 1341 | if (creamFindings.length > 0) { |
| 1342 | pageLevelFindings.push(...creamFindings); |
| 1343 | addBrowserFindings(groupMap, document.body, creamFindings); |
| 1344 | } |
| 1345 | |
| 1346 | // Regex-on-HTML checks (shared with Node) |
| 1347 | // Clone the document and strip impeccable-live overlay nodes before the |
| 1348 | // regex scan, so the inspector's own inline styles (transitions on top/ |
| 1349 | // left/width/height, etc.) don't register as page anti-patterns. |
| 1350 | const docClone = document.documentElement.cloneNode(true); |
| 1351 | for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { |
| 1352 | node.remove(); |
| 1353 | } |
| 1354 | const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); |
| 1355 | if (htmlPatternFindings.length > 0) { |
| 1356 | const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); |
| 1357 | pageLevelFindings.push(...mapped); |
| 1358 | addBrowserFindings(groupMap, document.body, mapped); |
| 1359 | } |
| 1360 | |
| 1361 | return { |
| 1362 | groupMap, |
| 1363 | allFindings: browserFindingsFromMap(groupMap), |
| 1364 | pageLevelFindings, |
| 1365 | }; |
| 1366 | } |
| 1367 | |
| 1368 | function shouldRunVisualContrast(options = {}) { |
| 1369 | return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; |
| 1370 | } |
| 1371 | |
| 1372 | function visualContrastOptions(options = {}) { |
| 1373 | const config = window.__IMPECCABLE_CONFIG__ || {}; |
| 1374 | const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' |
| 1375 | ? options.scrollOffscreen |
| 1376 | : typeof options.visualContrastScrollOffscreen === 'boolean' |
| 1377 | ? options.visualContrastScrollOffscreen |
| 1378 | : typeof config.visualContrastScrollOffscreen === 'boolean' |
| 1379 | ? config.visualContrastScrollOffscreen |
| 1380 | : false; |
| 1381 | return { |
| 1382 | ...options, |
| 1383 | maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) |
| 1384 | ? options.visualContrastMaxCandidates |
| 1385 | : Number.isFinite(options.maxCandidates) |
| 1386 | ? options.maxCandidates |
| 1387 | : Number.isFinite(config.visualContrastMaxCandidates) |
| 1388 | ? config.visualContrastMaxCandidates |
| 1389 | : undefined, |
| 1390 | scrollOffscreen, |
| 1391 | }; |
| 1392 | } |
| 1393 | |
| 1394 | let lastVisualContrastAnalyses = []; |
| 1395 | let lazyVisualContrastObserver = null; |
| 1396 | let lazyVisualContrastPending = new WeakMap(); |
| 1397 | const lazyVisualContrastResolving = new WeakSet(); |
| 1398 | let scanGeneration = 0; |
| 1399 | |
| 1400 | function rememberVisualContrastAnalysis(result) { |
| 1401 | if (!result?.selector) { |
| 1402 | lastVisualContrastAnalyses.push(result); |
| 1403 | return; |
| 1404 | } |
| 1405 | const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); |
| 1406 | if (idx >= 0) lastVisualContrastAnalyses[idx] = result; |
| 1407 | else lastVisualContrastAnalyses.push(result); |
| 1408 | } |
| 1409 | |
| 1410 | function disconnectLazyVisualContrastObserver() { |
| 1411 | if (lazyVisualContrastObserver) { |
| 1412 | lazyVisualContrastObserver.disconnect(); |
| 1413 | lazyVisualContrastObserver = null; |
| 1414 | } |
| 1415 | lazyVisualContrastPending = new WeakMap(); |
| 1416 | } |
| 1417 | |
| 1418 | function addVisualContrastResult(groupMap, result, options = {}) { |
| 1419 | if (result.status !== 'fail' || !result.finding || !result.selector) return false; |
| 1420 | let el = null; |
| 1421 | try { |
| 1422 | el = document.querySelector(result.selector); |
| 1423 | } catch { |
| 1424 | el = null; |
| 1425 | } |
| 1426 | if (!el) return false; |
| 1427 | const findingType = result.finding.type || result.finding.id || 'low-contrast'; |
| 1428 | const existing = groupMap.get(el) || []; |
| 1429 | if (existing.some(f => (f.type || f.id) === findingType)) return false; |
| 1430 | addBrowserFindings(groupMap, el, [{ |
| 1431 | type: findingType, |
| 1432 | detail: result.finding.detail || result.finding.snippet, |
| 1433 | }]); |
| 1434 | if (options.decorate && el !== document.body && el !== document.documentElement) { |
| 1435 | highlight(el, groupMap.get(el) || []); |
| 1436 | } |
| 1437 | return true; |
| 1438 | } |
| 1439 | |
| 1440 | function scanResultMeta(options = {}) { |
| 1441 | const scanId = options.scanId; |
| 1442 | if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; |
| 1443 | return { scanId: String(scanId) }; |
| 1444 | } |
| 1445 | |
| 1446 | function postSerializedFindings(groupMap, options = {}) { |
| 1447 | if (!EXTENSION_MODE) return; |
| 1448 | const allFindings = browserFindingsFromMap(groupMap); |
| 1449 | window.postMessage({ |
| 1450 | source: 'impeccable-results', |
| 1451 | findings: serializeFindings(allFindings), |
| 1452 | count: allFindings.length, |
| 1453 | ...scanResultMeta(options), |
| 1454 | }, '*'); |
| 1455 | } |
| 1456 | |
| 1457 | function postExtensionError(err) { |
| 1458 | if (!EXTENSION_MODE) return; |
| 1459 | window.postMessage({ |
| 1460 | source: 'impeccable-error', |
| 1461 | message: err?.message || String(err), |
| 1462 | }, '*'); |
| 1463 | } |
| 1464 | |
| 1465 | function reportVisualContrastError(err, detail = {}) { |
| 1466 | window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { |
| 1467 | detail: { |
| 1468 | ...detail, |
| 1469 | message: err?.message || String(err), |
| 1470 | }, |
| 1471 | })); |
| 1472 | if (EXTENSION_MODE) { |
| 1473 | postExtensionError(err); |
| 1474 | } else { |
| 1475 | console.warn('[impeccable] visual contrast scan failed', err); |
| 1476 | } |
| 1477 | } |
| 1478 | |
| 1479 | function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { |
| 1480 | disconnectLazyVisualContrastObserver(); |
| 1481 | if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; |
| 1482 | if (typeof IntersectionObserver === 'undefined') return; |
| 1483 | const unresolved = (analyses || []).filter(result => |
| 1484 | result?.status === 'unresolved' && |
| 1485 | result.reason === 'text outside viewport' && |
| 1486 | result.selector |
| 1487 | ); |
| 1488 | if (unresolved.length === 0) return; |
| 1489 | const generation = runtime.generation || scanGeneration; |
| 1490 | |
| 1491 | lazyVisualContrastObserver = new IntersectionObserver((entries) => { |
| 1492 | for (const entry of entries) { |
| 1493 | if (!entry.isIntersecting) continue; |
| 1494 | const el = entry.target; |
| 1495 | const candidate = lazyVisualContrastPending.get(el); |
| 1496 | if (!candidate || lazyVisualContrastResolving.has(el)) continue; |
| 1497 | lazyVisualContrastObserver?.unobserve(el); |
| 1498 | lazyVisualContrastPending.delete(el); |
| 1499 | lazyVisualContrastResolving.add(el); |
| 1500 | waitForVisualPaint() |
| 1501 | .then(() => analyzeVisualContrastCandidate(candidate)) |
| 1502 | .then(result => { |
| 1503 | if (generation !== scanGeneration) return; |
| 1504 | rememberVisualContrastAnalysis(result); |
| 1505 | const added = addVisualContrastResult(groupMap, result, { decorate: true }); |
| 1506 | if (added) { |
| 1507 | postSerializedFindings(groupMap, options); |
| 1508 | window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { |
| 1509 | detail: { |
| 1510 | selector: result.selector, |
| 1511 | status: result.status, |
| 1512 | finding: result.finding || null, |
| 1513 | }, |
| 1514 | })); |
| 1515 | } |
| 1516 | }) |
| 1517 | .catch(err => { |
| 1518 | reportVisualContrastError(err, { selector: candidate.selector }); |
| 1519 | }) |
| 1520 | .finally(() => { |
| 1521 | lazyVisualContrastResolving.delete(el); |
| 1522 | }); |
| 1523 | } |
| 1524 | }, { threshold: 0.5 }); |
| 1525 | |
| 1526 | for (const candidate of unresolved) { |
| 1527 | let el = null; |
| 1528 | try { |
| 1529 | el = document.querySelector(candidate.selector); |
| 1530 | } catch { |
| 1531 | el = null; |
| 1532 | } |
| 1533 | if (!el) continue; |
| 1534 | lazyVisualContrastPending.set(el, candidate); |
| 1535 | lazyVisualContrastObserver.observe(el); |
| 1536 | } |
| 1537 | } |
| 1538 | |
| 1539 | async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { |
| 1540 | if (!shouldRunVisualContrast(options)) { |
| 1541 | lastVisualContrastAnalyses = []; |
| 1542 | disconnectLazyVisualContrastObserver(); |
| 1543 | return []; |
| 1544 | } |
| 1545 | const resolvedOptions = visualContrastOptions(options); |
| 1546 | const analyses = await analyzeVisualContrast(resolvedOptions); |
| 1547 | if (runtime.generation && runtime.generation !== scanGeneration) return analyses; |
| 1548 | lastVisualContrastAnalyses = analyses; |
| 1549 | for (const result of analyses) { |
| 1550 | addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); |
| 1551 | } |
| 1552 | if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); |
| 1553 | return analyses; |
| 1554 | } |
| 1555 | |
| 1556 | async function collectBrowserFindingsAsync(options = {}, runtime = {}) { |
| 1557 | const collected = collectBrowserFindings(); |
| 1558 | await addVisualContrastFindings(collected.groupMap, options, runtime); |
| 1559 | return { |
| 1560 | ...collected, |
| 1561 | allFindings: browserFindingsFromMap(collected.groupMap), |
| 1562 | visualContrastAnalyses: lastVisualContrastAnalyses, |
| 1563 | }; |
| 1564 | } |
| 1565 | |
| 1566 | function clearOverlays() { |
| 1567 | scanGeneration += 1; |
| 1568 | disconnectLazyVisualContrastObserver(); |
| 1569 | for (const o of [...overlays]) detachOverlay(o); |
| 1570 | overlays.length = 0; |
| 1571 | visibilityObserver.disconnect(); |
| 1572 | overlayIndex = 0; |
| 1573 | } |
| 1574 | |
| 1575 | function renderBrowserFindings(collected, options = {}) { |
| 1576 | const { allFindings, pageLevelFindings } = collected; |
| 1577 | |
| 1578 | for (const { el, findings } of allFindings) { |
| 1579 | if (el === document.body || el === document.documentElement) continue; |
| 1580 | highlight(el, findings); |
| 1581 | } |
| 1582 | |
| 1583 | if (pageLevelFindings.length > 0) { |
| 1584 | showPageBanner(pageLevelFindings); |
| 1585 | } |
| 1586 | |
| 1587 | if (!EXTENSION_MODE) printSummary(allFindings); |
| 1588 | |
| 1589 | // In extension mode, post serialized results for the DevTools panel |
| 1590 | if (EXTENSION_MODE) { |
| 1591 | window.postMessage({ |
| 1592 | source: 'impeccable-results', |
| 1593 | findings: serializeFindings(allFindings), |
| 1594 | count: allFindings.length, |
| 1595 | ...scanResultMeta(options), |
| 1596 | }, '*'); |
| 1597 | } |
| 1598 | |
| 1599 | // After this scan completes, all subsequent reveals are instant (no stagger, no animation) |
| 1600 | setTimeout(() => { firstScanDone = true; }, 1000); |
| 1601 | |
| 1602 | return allFindings; |
| 1603 | } |
| 1604 | |
| 1605 | let firstScanDone = false; |
| 1606 | const scan = function(options = {}) { |
| 1607 | clearOverlays(); |
| 1608 | const generation = scanGeneration; |
| 1609 | const collected = collectBrowserFindings(); |
| 1610 | const allFindings = renderBrowserFindings(collected, options); |
| 1611 | if (shouldRunVisualContrast(options)) { |
| 1612 | addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) |
| 1613 | .then(() => { |
| 1614 | if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); |
| 1615 | }) |
| 1616 | .catch(err => { |
| 1617 | reportVisualContrastError(err); |
| 1618 | }); |
| 1619 | } |
| 1620 | return allFindings; |
| 1621 | }; |
| 1622 | |
| 1623 | const scanAsync = async function(options = {}) { |
| 1624 | clearOverlays(); |
| 1625 | const generation = scanGeneration; |
| 1626 | if (shouldRunVisualContrast(options)) { |
| 1627 | const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); |
| 1628 | if (generation !== scanGeneration) return []; |
| 1629 | return renderBrowserFindings(collected, options); |
| 1630 | } |
| 1631 | lastVisualContrastAnalyses = []; |
| 1632 | return renderBrowserFindings(collectBrowserFindings(), options); |
| 1633 | }; |
| 1634 | |
| 1635 | const detect = function(options = {}) { |
| 1636 | lastVisualContrastAnalyses = []; |
| 1637 | const { allFindings } = collectBrowserFindings(); |
| 1638 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 1639 | }; |
| 1640 | |
| 1641 | const detectAsync = async function(options = {}) { |
| 1642 | if (shouldRunVisualContrast(options)) { |
| 1643 | const { allFindings } = await collectBrowserFindingsAsync(options); |
| 1644 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 1645 | } |
| 1646 | lastVisualContrastAnalyses = []; |
| 1647 | const { allFindings } = collectBrowserFindings(); |
| 1648 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 1649 | }; |
| 1650 | |
| 1651 | if (EXTENSION_MODE) { |
| 1652 | // Extension mode: listen for commands, don't auto-scan |
| 1653 | window.addEventListener('message', (e) => { |
| 1654 | if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; |
| 1655 | if (e.data.action === 'scan') { |
| 1656 | if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; |
| 1657 | try { |
| 1658 | scan(e.data.config || {}); |
| 1659 | } catch (err) { |
| 1660 | postExtensionError(err); |
| 1661 | } |
| 1662 | } |
| 1663 | if (e.data.action === 'toggle-overlays') { |
| 1664 | const visible = !document.body.classList.contains('impeccable-hidden'); |
| 1665 | document.body.classList.toggle('impeccable-hidden', visible); |
| 1666 | window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); |
| 1667 | } |
| 1668 | if (e.data.action === 'remove') { |
| 1669 | clearOverlays(); |
| 1670 | styleEl.remove(); |
| 1671 | if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } |
| 1672 | document.body.classList.remove('impeccable-hidden'); |
| 1673 | } |
| 1674 | if (e.data.action === 'highlight') { |
| 1675 | try { |
| 1676 | const target = e.data.selector ? document.querySelector(e.data.selector) : null; |
| 1677 | if (target) { |
| 1678 | // Scroll first so positionOverlay reads the post-scroll rect |
| 1679 | if (!isInViewport(target) && target.scrollIntoView) { |
| 1680 | target.scrollIntoView({ behavior: 'instant', block: 'center' }); |
| 1681 | } |
| 1682 | for (const o of overlays) { |
| 1683 | if (o.classList.contains('impeccable-banner')) continue; |
| 1684 | const isMatch = o._targetEl === target; |
| 1685 | o.classList.toggle('impeccable-spotlight', isMatch); |
| 1686 | o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); |
| 1687 | if (isMatch) { |
| 1688 | // Force the matching overlay visible immediately, don't wait for IntersectionObserver |
| 1689 | o.style.display = ''; |
| 1690 | o.style.animation = 'none'; |
| 1691 | o.classList.add('impeccable-visible'); |
| 1692 | o._revealed = true; |
| 1693 | positionOverlay(o); |
| 1694 | } |
| 1695 | } |
| 1696 | showSpotlight(target); |
| 1697 | } |
| 1698 | } catch { /* invalid selector */ } |
| 1699 | } |
| 1700 | if (e.data.action === 'unhighlight') { |
| 1701 | hideSpotlight(); |
| 1702 | for (const o of overlays) { |
| 1703 | o.classList.remove('impeccable-spotlight'); |
| 1704 | o.classList.remove('impeccable-spotlight-dimmed'); |
| 1705 | } |
| 1706 | } |
| 1707 | }); |
| 1708 | window.postMessage({ source: 'impeccable-ready' }, '*'); |
| 1709 | } else { |
| 1710 | if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { |
| 1711 | const runAutoScan = () => { |
| 1712 | try { |
| 1713 | scan(); |
| 1714 | } catch (err) { |
| 1715 | console.warn('[impeccable] scan failed', err); |
| 1716 | } |
| 1717 | }; |
| 1718 | if (document.readyState === 'loading') { |
| 1719 | document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); |
| 1720 | } else { |
| 1721 | setTimeout(runAutoScan, 100); |
| 1722 | } |
| 1723 | } |
| 1724 | } |
| 1725 | |
| 1726 | window.impeccableDetect = detect; |
| 1727 | window.impeccableDetectAsync = detectAsync; |
| 1728 | window.impeccableScan = scan; |
| 1729 | window.impeccableScanAsync = scanAsync; |
| 1730 | window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; |
| 1731 | window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; |
| 1732 | window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); |
| 1733 | } |
| 1734 |