| 1 | /** |
| 2 | * Anti-Pattern Browser Detector for Impeccable |
| 3 | * Copyright (c) 2026 Paul Bakaus |
| 4 | * SPDX-License-Identifier: Apache-2.0 |
| 5 | * |
| 6 | * GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs |
| 7 | * Rebuild: node scripts/build-browser-detector.js |
| 8 | * |
| 9 | * Usage: <script src="detect-antipatterns-browser.js"></script> |
| 10 | * Re-scan: window.impeccableScan() |
| 11 | */ |
| 12 | (function () { |
| 13 | if (typeof window === 'undefined') return; |
| 14 | // --- cli/engine/shared/constants.mjs --- |
| 15 | // ─── Section 1: Constants ─────────────────────────────────────────────────── |
| 16 | |
| 17 | const SAFE_TAGS = new Set([ |
| 18 | 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', |
| 19 | 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', |
| 20 | 'button', 'hr', 'html', 'head', 'body', 'script', 'style', |
| 21 | 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', |
| 22 | 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', |
| 23 | ]); |
| 24 | |
| 25 | // Per-check safe-tags override for the border (side-tab / border-accent) |
| 26 | // rule. We intentionally re-allow <label> here because card-shaped clickable |
| 27 | // labels (e.g. .checklist-item wrapping a checkbox + content) are one of the |
| 28 | // canonical side-tab anti-pattern shapes and must be detected. The rule's |
| 29 | // other preconditions (non-neutral color, width >= 2px on a single side, |
| 30 | // radius > 0 or width >= 3, element size >= 20x20 in the browser path) |
| 31 | // already filter out plain inline form labels so this does not introduce |
| 32 | // false positives. See modern-color-borders.html for the test matrix. |
| 33 | const BORDER_SAFE_TAGS = new Set( |
| 34 | [...SAFE_TAGS].filter(t => t !== 'label') |
| 35 | ); |
| 36 | |
| 37 | const OVERUSED_FONTS = new Set([ |
| 38 | // Older monoculture (still ubiquitous): |
| 39 | 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', |
| 40 | // Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave): |
| 41 | 'fraunces', 'instrument sans', 'instrument serif', |
| 42 | 'geist', 'geist sans', 'geist mono', |
| 43 | 'mona sans', |
| 44 | 'plus jakarta sans', 'space grotesk', 'recoleta', |
| 45 | ]); |
| 46 | |
| 47 | // Brand-associated fonts: don't flag these as "overused" on the brand's own domains. |
| 48 | // Keys are font names, values are arrays of hostname suffixes where the font is allowed. |
| 49 | const GOOGLE_DOMAINS = [ |
| 50 | 'google.com', 'youtube.com', 'android.com', 'chromium.org', |
| 51 | 'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com', |
| 52 | ]; |
| 53 | const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app']; |
| 54 | const GITHUB_DOMAINS = ['github.com', 'githubnext.com']; |
| 55 | const BRAND_FONT_DOMAINS = { |
| 56 | 'roboto': GOOGLE_DOMAINS, |
| 57 | 'google sans': GOOGLE_DOMAINS, |
| 58 | 'product sans': GOOGLE_DOMAINS, |
| 59 | 'geist': VERCEL_DOMAINS, |
| 60 | 'geist sans': VERCEL_DOMAINS, |
| 61 | 'geist mono': VERCEL_DOMAINS, |
| 62 | 'mona sans': GITHUB_DOMAINS, |
| 63 | }; |
| 64 | |
| 65 | function isBrandFontOnOwnDomain(font) { |
| 66 | if (typeof location === 'undefined') return false; |
| 67 | const allowed = BRAND_FONT_DOMAINS[font]; |
| 68 | if (!allowed) return false; |
| 69 | const host = location.hostname.toLowerCase(); |
| 70 | return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix)); |
| 71 | } |
| 72 | |
| 73 | const GENERIC_FONTS = new Set([ |
| 74 | 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', |
| 75 | 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', |
| 76 | '-apple-system', 'blinkmacsystemfont', 'segoe ui', |
| 77 | 'inherit', 'initial', 'unset', 'revert', |
| 78 | ]); |
| 79 | |
| 80 | // WCAG large text thresholds are defined in points: 18pt normal text and |
| 81 | // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch. |
| 82 | const WCAG_LARGE_TEXT_PX = 18 * (96 / 72); |
| 83 | const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72); |
| 84 | |
| 85 | // Serif faces that show up in italic-display heroes. The rule also fires when |
| 86 | // the primary face is unknown but the stack ends in the generic `serif` token, |
| 87 | // which catches custom/private faces with a serif fallback. |
| 88 | const KNOWN_SERIF_FONTS = new Set([ |
| 89 | 'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair', |
| 90 | 'cormorant', 'cormorant garamond', 'garamond', 'eb garamond', |
| 91 | 'tiempos', 'tiempos headline', 'tiempos text', |
| 92 | 'lora', 'vollkorn', 'spectral', |
| 93 | 'source serif pro', 'source serif 4', 'source serif', |
| 94 | 'ibm plex serif', 'merriweather', |
| 95 | 'libre caslon', 'libre baskerville', 'baskerville', |
| 96 | 'georgia', 'times new roman', 'times', |
| 97 | 'dm serif display', 'dm serif text', |
| 98 | 'instrument serif', 'gt sectra', 'ogg', 'canela', |
| 99 | 'freight display', 'freight text', |
| 100 | ]); |
| 101 | |
| 102 | // --- cli/engine/registry/antipatterns.mjs --- |
| 103 | const ANTIPATTERNS = [ |
| 104 | // ── AI slop: tells that something was AI-generated ── |
| 105 | { |
| 106 | id: 'side-tab', |
| 107 | category: 'slop', |
| 108 | name: 'Side-tab accent border', |
| 109 | description: |
| 110 | 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', |
| 111 | skillSection: 'Visual Details', |
| 112 | skillGuideline: 'colored accent stripe', |
| 113 | }, |
| 114 | { |
| 115 | id: 'border-accent-on-rounded', |
| 116 | category: 'slop', |
| 117 | name: 'Border accent on rounded element', |
| 118 | description: |
| 119 | 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', |
| 120 | skillSection: 'Visual Details', |
| 121 | skillGuideline: 'colored accent stripe', |
| 122 | }, |
| 123 | { |
| 124 | id: 'overused-font', |
| 125 | category: 'slop', |
| 126 | name: 'Overused font', |
| 127 | description: |
| 128 | 'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.', |
| 129 | skillSection: 'Typography', |
| 130 | skillGuideline: 'overused fonts like Inter', |
| 131 | }, |
| 132 | { |
| 133 | id: 'single-font', |
| 134 | category: 'slop', |
| 135 | name: 'Single font for everything', |
| 136 | description: |
| 137 | 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', |
| 138 | skillSection: 'Typography', |
| 139 | skillGuideline: 'only one font family for the entire page', |
| 140 | }, |
| 141 | { |
| 142 | id: 'flat-type-hierarchy', |
| 143 | category: 'slop', |
| 144 | name: 'Flat type hierarchy', |
| 145 | description: |
| 146 | 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', |
| 147 | skillSection: 'Typography', |
| 148 | skillGuideline: 'flat type hierarchy', |
| 149 | }, |
| 150 | { |
| 151 | id: 'gradient-text', |
| 152 | category: 'slop', |
| 153 | name: 'Gradient text', |
| 154 | description: |
| 155 | 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', |
| 156 | skillSection: 'Color & Contrast', |
| 157 | skillGuideline: 'gradient text for', |
| 158 | }, |
| 159 | { |
| 160 | id: 'ai-color-palette', |
| 161 | category: 'slop', |
| 162 | name: 'AI color palette', |
| 163 | description: |
| 164 | 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', |
| 165 | skillSection: 'Color & Contrast', |
| 166 | skillGuideline: 'AI color palette', |
| 167 | }, |
| 168 | { |
| 169 | id: 'cream-palette', |
| 170 | category: 'slop', |
| 171 | name: 'Cream / beige palette', |
| 172 | description: |
| 173 | 'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.', |
| 174 | skillSection: 'Color & Contrast', |
| 175 | skillGuideline: 'cream and beige as the default surface', |
| 176 | }, |
| 177 | { |
| 178 | id: 'nested-cards', |
| 179 | category: 'slop', |
| 180 | name: 'Nested cards', |
| 181 | description: |
| 182 | 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', |
| 183 | skillSection: 'Layout & Space', |
| 184 | skillGuideline: 'Nest cards inside cards', |
| 185 | }, |
| 186 | { |
| 187 | id: 'monotonous-spacing', |
| 188 | category: 'slop', |
| 189 | name: 'Monotonous spacing', |
| 190 | description: |
| 191 | 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', |
| 192 | skillSection: 'Layout & Space', |
| 193 | skillGuideline: 'same spacing everywhere', |
| 194 | }, |
| 195 | { |
| 196 | id: 'bounce-easing', |
| 197 | category: 'slop', |
| 198 | name: 'Bounce or elastic easing', |
| 199 | description: |
| 200 | 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', |
| 201 | skillSection: 'Motion', |
| 202 | skillGuideline: 'bounce or elastic easing', |
| 203 | }, |
| 204 | { |
| 205 | id: 'dark-glow', |
| 206 | category: 'slop', |
| 207 | name: 'Dark mode with glowing accents', |
| 208 | description: |
| 209 | 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', |
| 210 | skillSection: 'Color & Contrast', |
| 211 | skillGuideline: 'dark mode with glowing accents', |
| 212 | }, |
| 213 | { |
| 214 | id: 'icon-tile-stack', |
| 215 | category: 'slop', |
| 216 | name: 'Icon tile stacked above heading', |
| 217 | description: |
| 218 | 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.', |
| 219 | skillSection: 'Typography', |
| 220 | skillGuideline: 'large icons with rounded corners above every heading', |
| 221 | }, |
| 222 | { |
| 223 | id: 'italic-serif-display', |
| 224 | category: 'slop', |
| 225 | name: 'Italic serif display headline', |
| 226 | description: |
| 227 | 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.', |
| 228 | skillSection: 'Typography', |
| 229 | skillGuideline: 'oversized italic serif as the hero headline', |
| 230 | }, |
| 231 | { |
| 232 | id: 'hero-eyebrow-chip', |
| 233 | category: 'slop', |
| 234 | name: 'Hero eyebrow / pill chip', |
| 235 | description: |
| 236 | 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.', |
| 237 | skillSection: 'Typography', |
| 238 | skillGuideline: 'tiny uppercase tracked label above the hero headline', |
| 239 | }, |
| 240 | { |
| 241 | id: 'repeated-section-kickers', |
| 242 | category: 'slop', |
| 243 | severity: 'advisory', |
| 244 | name: 'Repeated section kicker labels', |
| 245 | description: |
| 246 | 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.', |
| 247 | skillSection: 'Typography', |
| 248 | skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', |
| 249 | }, |
| 250 | { |
| 251 | id: 'numbered-section-markers', |
| 252 | category: 'slop', |
| 253 | severity: 'advisory', |
| 254 | name: 'Numbered section markers (01 / 02 / 03)', |
| 255 | description: |
| 256 | 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', |
| 257 | skillSection: 'Layout & Space', |
| 258 | skillGuideline: 'numbered section markers', |
| 259 | }, |
| 260 | { |
| 261 | id: 'em-dash-overuse', |
| 262 | category: 'slop', |
| 263 | name: 'Em-dash overuse', |
| 264 | description: |
| 265 | 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', |
| 266 | skillSection: 'Copy', |
| 267 | skillGuideline: 'no em dashes', |
| 268 | }, |
| 269 | { |
| 270 | id: 'marketing-buzzword', |
| 271 | category: 'slop', |
| 272 | name: 'Marketing buzzword', |
| 273 | description: |
| 274 | 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', |
| 275 | skillSection: 'Copy', |
| 276 | skillGuideline: 'marketing buzzwords', |
| 277 | }, |
| 278 | { |
| 279 | id: 'aphoristic-cadence', |
| 280 | category: 'slop', |
| 281 | name: 'Aphoristic-cadence copy', |
| 282 | description: |
| 283 | 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', |
| 284 | skillSection: 'Copy', |
| 285 | skillGuideline: 'aphoristic cadence', |
| 286 | }, |
| 287 | { |
| 288 | id: 'oversized-h1', |
| 289 | category: 'slop', |
| 290 | name: 'Oversized hero headline', |
| 291 | description: |
| 292 | 'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.', |
| 293 | skillSection: 'Typography', |
| 294 | skillGuideline: 'long headline set at display size', |
| 295 | }, |
| 296 | { |
| 297 | id: 'extreme-negative-tracking', |
| 298 | category: 'slop', |
| 299 | name: 'Crushed letter spacing', |
| 300 | description: |
| 301 | 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.', |
| 302 | skillSection: 'Typography', |
| 303 | skillGuideline: 'letter spacing crushed past legibility', |
| 304 | }, |
| 305 | { |
| 306 | id: 'broken-image', |
| 307 | category: 'quality', |
| 308 | name: 'Broken or placeholder image', |
| 309 | description: |
| 310 | '<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', |
| 311 | skillSection: 'Imagery', |
| 312 | skillGuideline: 'broken image references', |
| 313 | }, |
| 314 | |
| 315 | // ── Quality: general design and accessibility issues ── |
| 316 | { |
| 317 | id: 'gray-on-color', |
| 318 | category: 'quality', |
| 319 | name: 'Gray text on colored background', |
| 320 | description: |
| 321 | 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', |
| 322 | skillSection: 'Color & Contrast', |
| 323 | skillGuideline: 'gray text on colored backgrounds', |
| 324 | }, |
| 325 | { |
| 326 | id: 'low-contrast', |
| 327 | category: 'quality', |
| 328 | name: 'Low contrast text', |
| 329 | description: |
| 330 | 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', |
| 331 | }, |
| 332 | { |
| 333 | id: 'layout-transition', |
| 334 | category: 'quality', |
| 335 | name: 'Layout property animation', |
| 336 | description: |
| 337 | 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', |
| 338 | skillSection: 'Motion', |
| 339 | skillGuideline: 'Animate layout properties', |
| 340 | }, |
| 341 | { |
| 342 | id: 'line-length', |
| 343 | category: 'quality', |
| 344 | name: 'Line length too long', |
| 345 | description: |
| 346 | 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', |
| 347 | skillSection: 'Layout & Space', |
| 348 | skillGuideline: 'wrap beyond ~80 characters', |
| 349 | }, |
| 350 | { |
| 351 | id: 'cramped-padding', |
| 352 | category: 'quality', |
| 353 | name: 'Cramped padding', |
| 354 | description: |
| 355 | 'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.', |
| 356 | skillSection: 'Layout & Space', |
| 357 | skillGuideline: 'inside bordered or colored containers', |
| 358 | }, |
| 359 | { |
| 360 | id: 'body-text-viewport-edge', |
| 361 | category: 'quality', |
| 362 | name: 'Body text touching viewport edge', |
| 363 | description: |
| 364 | 'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.', |
| 365 | }, |
| 366 | { |
| 367 | id: 'tight-leading', |
| 368 | category: 'quality', |
| 369 | name: 'Tight line height', |
| 370 | description: |
| 371 | 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', |
| 372 | }, |
| 373 | { |
| 374 | id: 'skipped-heading', |
| 375 | category: 'quality', |
| 376 | name: 'Skipped heading level', |
| 377 | description: |
| 378 | 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', |
| 379 | }, |
| 380 | { |
| 381 | id: 'justified-text', |
| 382 | category: 'quality', |
| 383 | name: 'Justified text', |
| 384 | description: |
| 385 | 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', |
| 386 | }, |
| 387 | { |
| 388 | id: 'tiny-text', |
| 389 | category: 'quality', |
| 390 | name: 'Tiny body text', |
| 391 | description: |
| 392 | 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', |
| 393 | }, |
| 394 | { |
| 395 | id: 'all-caps-body', |
| 396 | category: 'quality', |
| 397 | name: 'All-caps body text', |
| 398 | description: |
| 399 | 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', |
| 400 | skillSection: 'Typography', |
| 401 | skillGuideline: 'long body passages in uppercase', |
| 402 | }, |
| 403 | { |
| 404 | id: 'wide-tracking', |
| 405 | category: 'quality', |
| 406 | name: 'Wide letter spacing on body text', |
| 407 | description: |
| 408 | 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', |
| 409 | }, |
| 410 | { |
| 411 | id: 'text-overflow', |
| 412 | category: 'quality', |
| 413 | name: 'Content overflowing its container', |
| 414 | description: |
| 415 | 'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.', |
| 416 | skillSection: 'Layout & Space', |
| 417 | skillGuideline: 'content wider than its container', |
| 418 | }, |
| 419 | { |
| 420 | id: 'clipped-overflow-container', |
| 421 | category: 'quality', |
| 422 | name: 'Positioned child clipped by overflow container', |
| 423 | description: |
| 424 | 'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.', |
| 425 | skillSection: 'Layout & Space', |
| 426 | skillGuideline: 'overflow container clipping positioned children', |
| 427 | }, |
| 428 | |
| 429 | // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ── |
| 430 | { |
| 431 | id: 'gpt-thin-border-wide-shadow', |
| 432 | category: 'slop', |
| 433 | severity: 'advisory', |
| 434 | gated: 'gpt', |
| 435 | name: 'Hairline border with wide shadow', |
| 436 | description: |
| 437 | 'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.', |
| 438 | skillSection: 'Visual Details', |
| 439 | skillGuideline: 'hairline border plus wide diffuse shadow', |
| 440 | }, |
| 441 | { |
| 442 | id: 'repeating-stripes-gradient', |
| 443 | category: 'slop', |
| 444 | severity: 'advisory', |
| 445 | gated: 'gpt', |
| 446 | name: 'Repeating-gradient stripes', |
| 447 | description: |
| 448 | 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.', |
| 449 | skillSection: 'Visual Details', |
| 450 | skillGuideline: 'repeating-gradient decorative stripes', |
| 451 | }, |
| 452 | { |
| 453 | id: 'theater-slop-phrase', |
| 454 | category: 'slop', |
| 455 | severity: 'advisory', |
| 456 | gated: 'gpt', |
| 457 | name: 'Theater framing copy', |
| 458 | description: |
| 459 | 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.', |
| 460 | skillSection: 'Copy', |
| 461 | skillGuideline: 'theater framing copy', |
| 462 | }, |
| 463 | { |
| 464 | id: 'image-hover-transform', |
| 465 | category: 'slop', |
| 466 | severity: 'advisory', |
| 467 | gated: 'gemini', |
| 468 | name: 'Image hover transform', |
| 469 | description: |
| 470 | 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.', |
| 471 | skillSection: 'Motion', |
| 472 | skillGuideline: 'image scale or rotate on hover', |
| 473 | }, |
| 474 | ]; |
| 475 | |
| 476 | // --- cli/engine/shared/color.mjs --- |
| 477 | // ─── Section 2: Color Utilities ───────────────────────────────────────────── |
| 478 | |
| 479 | function isNeutralColor(color) { |
| 480 | if (!color || color === 'transparent') return true; |
| 481 | |
| 482 | // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range. |
| 483 | const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); |
| 484 | if (rgb) { |
| 485 | return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30; |
| 486 | } |
| 487 | |
| 488 | // oklch()/lch() — chroma is the second numeric component. |
| 489 | // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. |
| 490 | // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats |
| 491 | // literally (it does NOT convert them to rgb). |
| 492 | const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); |
| 493 | if (oklch) return parseFloat(oklch[1]) < 0.02; |
| 494 | const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); |
| 495 | if (lch) return parseFloat(lch[1]) < 3; |
| 496 | |
| 497 | // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). |
| 498 | // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. |
| 499 | const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); |
| 500 | if (oklab) { |
| 501 | const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); |
| 502 | return Math.hypot(a, b) < 0.02; |
| 503 | } |
| 504 | const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); |
| 505 | if (lab) { |
| 506 | const a = parseFloat(lab[1]), b = parseFloat(lab[2]); |
| 507 | return Math.hypot(a, b) < 3; |
| 508 | } |
| 509 | |
| 510 | // hsl/hsla — saturation is the second numeric component (percent). |
| 511 | // Modern jsdom usually converts hsl() to rgb, but handle it directly for |
| 512 | // safety across versions and for any engine that preserves the format. |
| 513 | const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i); |
| 514 | if (hsl) return parseFloat(hsl[1]) < 10; |
| 515 | |
| 516 | // hwb(hue whiteness% blackness%) — a pixel is fully gray when |
| 517 | // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100. |
| 518 | const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i); |
| 519 | if (hwb) { |
| 520 | const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]); |
| 521 | return (1 - Math.min(100, w + b) / 100) < 0.1; |
| 522 | } |
| 523 | |
| 524 | // Unknown / unrecognized format — err on the side of DETECTING rather |
| 525 | // than silently skipping. This is the opposite of the previous default, |
| 526 | // which was the root cause of the oklch bug. |
| 527 | return false; |
| 528 | } |
| 529 | |
| 530 | function parseRgb(color) { |
| 531 | if (!color || color === 'transparent') return null; |
| 532 | const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); |
| 533 | if (!m) return null; |
| 534 | return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 }; |
| 535 | } |
| 536 | |
| 537 | function relativeLuminance({ r, g, b }) { |
| 538 | const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c => |
| 539 | c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 |
| 540 | ); |
| 541 | return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; |
| 542 | } |
| 543 | |
| 544 | function contrastRatio(c1, c2) { |
| 545 | const l1 = relativeLuminance(c1); |
| 546 | const l2 = relativeLuminance(c2); |
| 547 | return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); |
| 548 | } |
| 549 | |
| 550 | function parseGradientColors(bgImage) { |
| 551 | if (!bgImage || !bgImage.includes('gradient')) return []; |
| 552 | const colors = []; |
| 553 | for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) { |
| 554 | const c = parseRgb(m[0]); |
| 555 | if (c) colors.push(c); |
| 556 | } |
| 557 | for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) { |
| 558 | const h = m[1]; |
| 559 | if (h.length === 6) { |
| 560 | colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 }); |
| 561 | } else { |
| 562 | colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 }); |
| 563 | } |
| 564 | } |
| 565 | return colors; |
| 566 | } |
| 567 | |
| 568 | function hasChroma(c, threshold = 30) { |
| 569 | if (!c) return false; |
| 570 | return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold; |
| 571 | } |
| 572 | |
| 573 | function getHue(c) { |
| 574 | if (!c) return 0; |
| 575 | const r = c.r / 255, g = c.g / 255, b = c.b / 255; |
| 576 | const max = Math.max(r, g, b), min = Math.min(r, g, b); |
| 577 | if (max === min) return 0; |
| 578 | const d = max - min; |
| 579 | let h; |
| 580 | if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; |
| 581 | else if (max === g) h = ((b - r) / d + 2) / 6; |
| 582 | else h = ((r - g) / d + 4) / 6; |
| 583 | return Math.round(h * 360); |
| 584 | } |
| 585 | |
| 586 | function colorToHex(c) { |
| 587 | if (!c) return '?'; |
| 588 | return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); |
| 589 | } |
| 590 | |
| 591 | // --- cli/engine/rules/checks.mjs --- |
| 592 | const DETECTOR_IS_BROWSER = typeof window !== 'undefined'; |
| 593 | |
| 594 | // ─── Section 3: Pure Detection ────────────────────────────────────────────── |
| 595 | |
| 596 | function checkBorders(tag, widths, colors, radius) { |
| 597 | if (BORDER_SAFE_TAGS.has(tag)) return []; |
| 598 | const findings = []; |
| 599 | const sides = ['Top', 'Right', 'Bottom', 'Left']; |
| 600 | |
| 601 | for (const side of sides) { |
| 602 | const w = widths[side]; |
| 603 | if (w < 1 || isNeutralColor(colors[side])) continue; |
| 604 | |
| 605 | const otherSides = sides.filter(s => s !== side); |
| 606 | const maxOther = Math.max(...otherSides.map(s => widths[s])); |
| 607 | if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; |
| 608 | |
| 609 | const sn = side.toLowerCase(); |
| 610 | const isSide = side === 'Left' || side === 'Right'; |
| 611 | |
| 612 | if (isSide) { |
| 613 | if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); |
| 614 | else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); |
| 615 | } else { |
| 616 | if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | return findings; |
| 621 | } |
| 622 | |
| 623 | // Returns true if the given text is composed entirely of emoji characters |
| 624 | // (plus whitespace / variation selectors). Emojis render as multicolor glyphs |
| 625 | // regardless of CSS `color`, so contrast checks against the element's text |
| 626 | // color are meaningless for these nodes. |
| 627 | const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u; |
| 628 | const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu; |
| 629 | function isEmojiOnlyText(text) { |
| 630 | if (!text) return false; |
| 631 | if (!EMOJI_CHAR_RE.test(text)) return false; |
| 632 | return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === ''; |
| 633 | } |
| 634 | |
| 635 | function checkColors(opts) { |
| 636 | const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts; |
| 637 | if (SAFE_TAGS.has(tag)) { |
| 638 | // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS |
| 639 | // exists to suppress contrast noise on inline links and unstyled controls, |
| 640 | // where the element has no own background and the contrast against the |
| 641 | // ancestor surface is already the intended visual. When the element has |
| 642 | // its own opaque background and direct text, it is a styled button — and |
| 643 | // contrast on its own surface is a real, frequent bug worth flagging. |
| 644 | const isStyledButton = (tag === 'a' || tag === 'button') |
| 645 | && hasDirectText |
| 646 | && bgColor && bgColor.a > 0.5; |
| 647 | if (!isStyledButton) return []; |
| 648 | } |
| 649 | const findings = []; |
| 650 | |
| 651 | if (hasDirectText && textColor && !isEmojiOnly) { |
| 652 | // Run background-dependent checks against either a solid bg or, if the |
| 653 | // ancestor is a gradient, against every gradient stop (use the worst case). |
| 654 | const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); |
| 655 | if (bgs) { |
| 656 | // Gray on colored background — flag if every stop is chromatic |
| 657 | const textLum = relativeLuminance(textColor); |
| 658 | const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85; |
| 659 | if (isGray && bgs.every(b => hasChroma(b, 40))) { |
| 660 | const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`; |
| 661 | findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` }); |
| 662 | } |
| 663 | |
| 664 | // Low contrast (WCAG AA) — worst case across all bg stops |
| 665 | const ratios = bgs.map(b => contrastRatio(textColor, b)); |
| 666 | let worstIdx = 0; |
| 667 | for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; |
| 668 | const ratio = ratios[worstIdx]; |
| 669 | const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); |
| 670 | const threshold = isLargeText ? 3.0 : 4.5; |
| 671 | if (ratio < threshold) { |
| 672 | // Skip the false-positive class where text has alpha < 1 AND we |
| 673 | // couldn't find an opaque ancestor (effectiveBg is null, we're |
| 674 | // comparing against gradient-stop fallback). In jsdom mode the |
| 675 | // detector can't resolve `var(--X)` color tokens, so a dark |
| 676 | // section sitting between the text and the body's decorative |
| 677 | // gradient is invisible to us — we end up measuring contrast |
| 678 | // against the body's paper-grain noise instead of the real |
| 679 | // local bg. Real low-contrast bugs use alpha=1 and have a |
| 680 | // resolvable opaque ancestor; semi-transparent Tailwind tokens |
| 681 | // like `text-paper/60` on `bg-ink` sections are the FP pattern. |
| 682 | const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); |
| 683 | if (!isAlphaFallbackFP) { |
| 684 | findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); |
| 685 | } |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | // AI palette: purple/violet on headings |
| 690 | if (hasChroma(textColor, 50)) { |
| 691 | const hue = getHue(textColor); |
| 692 | if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) { |
| 693 | findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` }); |
| 694 | } |
| 695 | } |
| 696 | } |
| 697 | |
| 698 | // Gradient text |
| 699 | if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) { |
| 700 | findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); |
| 701 | } |
| 702 | |
| 703 | // Tailwind class checks |
| 704 | if (classList) { |
| 705 | const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); |
| 706 | |
| 707 | const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); |
| 708 | const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); |
| 709 | if (grayMatch && colorBgMatch) { |
| 710 | findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); |
| 711 | } |
| 712 | |
| 713 | if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) { |
| 714 | findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); |
| 715 | } |
| 716 | |
| 717 | const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/); |
| 718 | if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) { |
| 719 | findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` }); |
| 720 | } |
| 721 | |
| 722 | if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) { |
| 723 | findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' }); |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | return findings; |
| 728 | } |
| 729 | |
| 730 | function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { |
| 731 | if (!hasShadow && !hasBorder) return false; |
| 732 | return hasRadius || hasBg; |
| 733 | } |
| 734 | |
| 735 | const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); |
| 736 | |
| 737 | // Pure check: given a heading and metrics about its previousElementSibling, |
| 738 | // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape. |
| 739 | // |
| 740 | // Triggers when ALL of the following hold for the sibling: |
| 741 | // • size 32–128px on both axes (not too small, not a hero image) |
| 742 | // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges) |
| 743 | // • has a non-transparent background-color, background-image, OR a visible border |
| 744 | // (covers solid colors, white-with-border, gradients — anything that visually |
| 745 | // defines a tile) |
| 746 | // • border-radius < width/2 (excludes round avatars; rounded squares pass) |
| 747 | // • contains an <svg> or icon-class <i> element that's smaller than the tile |
| 748 | // • the tile sits above the heading (its bottom is above the heading's top) |
| 749 | function checkIconTile(opts) { |
| 750 | const { headingTag, headingText, headingTop, |
| 751 | siblingTag, siblingWidth, siblingHeight, siblingBottom, |
| 752 | siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius, |
| 753 | hasIconChild, iconChildWidth } = opts; |
| 754 | if (!HEADING_TAGS.has(headingTag)) return []; |
| 755 | if (!siblingTag) return []; |
| 756 | // Don't recurse into nested headings (e.g. h2 above h3 in a section header) |
| 757 | if (HEADING_TAGS.has(siblingTag)) return []; |
| 758 | |
| 759 | // Size window: 32–128px on each axis |
| 760 | if (!(siblingWidth >= 32 && siblingWidth <= 128)) return []; |
| 761 | if (!(siblingHeight >= 32 && siblingHeight <= 128)) return []; |
| 762 | |
| 763 | // Squarish aspect ratio |
| 764 | const ratio = siblingWidth / siblingHeight; |
| 765 | if (ratio < 0.7 || ratio > 1.4) return []; |
| 766 | |
| 767 | // Must have something that visually defines the tile |
| 768 | const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1) |
| 769 | || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== ''); |
| 770 | const borderVisible = siblingBorderWidth > 0; |
| 771 | if (!bgVisible && !borderVisible) return []; |
| 772 | |
| 773 | // Exclude circles (avatars). Rounded squares pass. |
| 774 | if (siblingBorderRadius >= siblingWidth / 2) return []; |
| 775 | |
| 776 | // Must contain an icon element smaller than the tile |
| 777 | if (!hasIconChild) return []; |
| 778 | if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return []; |
| 779 | |
| 780 | // Vertical stacking: tile must end above where the heading starts. |
| 781 | // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.) |
| 782 | if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return []; |
| 783 | |
| 784 | const text = (headingText || '').trim().slice(0, 60); |
| 785 | return [{ |
| 786 | id: 'icon-tile-stack', |
| 787 | snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`, |
| 788 | }]; |
| 789 | } |
| 790 | |
| 791 | // Resolve the primary (non-generic) face from a font-family string and return |
| 792 | // whether the resolved primary is serif. Two paths: |
| 793 | // 1. Primary face is in KNOWN_SERIF_FONTS → serif. |
| 794 | // 2. Primary face is unknown but the stack ends in the generic `serif` |
| 795 | // token → treat as serif. Authors who declare `font-family: 'X', serif` |
| 796 | // almost always have a serif primary; a sans declared with a serif |
| 797 | // fallback is a code smell, not the common case. |
| 798 | // Returns { primary, isSerif } so the snippet can name the face. |
| 799 | function resolveSerif(fontFamily) { |
| 800 | if (!fontFamily) return { primary: null, isSerif: false }; |
| 801 | const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); |
| 802 | const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null; |
| 803 | if (!primary) return { primary: null, isSerif: false }; |
| 804 | if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true }; |
| 805 | if (tokens.includes('serif')) return { primary, isSerif: true }; |
| 806 | return { primary, isSerif: false }; |
| 807 | } |
| 808 | |
| 809 | function checkItalicSerif(opts) { |
| 810 | const { tag, fontStyle, fontFamily, fontSize, headingText } = opts; |
| 811 | if (fontStyle !== 'italic') return []; |
| 812 | // Anchor the rule on hero-scale text. h1 is the canonical hero element; |
| 813 | // h2 ≥ 48px catches the cases where the design demotes the visual hero |
| 814 | // to an h2 but keeps the size. |
| 815 | if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return []; |
| 816 | if (fontSize < 48) return []; |
| 817 | const { primary, isSerif } = resolveSerif(fontFamily); |
| 818 | if (!isSerif) return []; |
| 819 | |
| 820 | const text = (headingText || '').trim().slice(0, 60); |
| 821 | return [{ |
| 822 | id: 'italic-serif-display', |
| 823 | snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`, |
| 824 | }]; |
| 825 | } |
| 826 | |
| 827 | // Color saturation check. Returns true when the color has visible |
| 828 | // chroma — i.e., it's an "accent color" rather than near-neutral. |
| 829 | // Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are |
| 830 | // expected to be pre-resolved by the caller. |
| 831 | function isAccentColor(cssColor) { |
| 832 | if (!cssColor) return false; |
| 833 | const s = String(cssColor).trim(); |
| 834 | // rgb / rgba — direct channel-distance check. |
| 835 | const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', ')); |
| 836 | const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s); |
| 837 | if (rgbStrict) { |
| 838 | const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3]; |
| 839 | return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; |
| 840 | } |
| 841 | // #hex — 3, 4, 6, or 8 digit. |
| 842 | const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s); |
| 843 | if (hexM) { |
| 844 | let h = hexM[1]; |
| 845 | if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6); |
| 846 | else h = h.slice(0, 6); |
| 847 | if (h.length === 6) { |
| 848 | const r = parseInt(h.slice(0, 2), 16); |
| 849 | const g = parseInt(h.slice(2, 4), 16); |
| 850 | const b = parseInt(h.slice(4, 6), 16); |
| 851 | return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40; |
| 852 | } |
| 853 | } |
| 854 | // oklch(L C H) — chroma C is what matters. Typical neutral grays |
| 855 | // have C < 0.02; visible accents are 0.05+. CSS minification can |
| 856 | // collapse spaces between L% and C ("oklch(43%.15 34)"), so we |
| 857 | // extract all numbers and take the second rather than matching a |
| 858 | // strict L-then-whitespace-then-C pattern. |
| 859 | if (/^oklch\(/i.test(s)) { |
| 860 | const nums = s.match(/\d*\.\d+|\d+/g); |
| 861 | if (nums && nums.length >= 2) { |
| 862 | const c = parseFloat(nums[1]); |
| 863 | return !Number.isNaN(c) && c >= 0.05; |
| 864 | } |
| 865 | } |
| 866 | // hsl(H, S%, L%) — saturation > 20% reads as accent. |
| 867 | const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s); |
| 868 | if (hslM) { |
| 869 | const sat = parseFloat(hslM[1]); |
| 870 | return !Number.isNaN(sat) && sat >= 20; |
| 871 | } |
| 872 | return false; |
| 873 | } |
| 874 | |
| 875 | // Sibling-relationship rule. Anchor on a hero-scale h1, look at the |
| 876 | // previousElementSibling, and gate on EITHER the classic tracked- |
| 877 | // uppercase eyebrow OR the modern accent-colored bold eyebrow. |
| 878 | function checkHeroEyebrow(opts) { |
| 879 | const { |
| 880 | headingTag, headingText, headingFontSize, |
| 881 | siblingTag, siblingText, siblingTextTransform, |
| 882 | siblingFontSize, siblingLetterSpacing, |
| 883 | siblingFontWeight, siblingColor, |
| 884 | } = opts; |
| 885 | if (headingTag !== 'h1') return []; |
| 886 | // We previously gated on headingFontSize >= 48 to anchor "hero scale". |
| 887 | // But modern hero h1s use clamp() / vw / var(--text-*), none of which |
| 888 | // jsdom can resolve — the computed value comes back as "2em" or |
| 889 | // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails |
| 890 | // on virtually every Tailwind v4 / framework build. The other gates |
| 891 | // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR |
| 892 | // tracked-caps) are tight enough to avoid false positives on non- |
| 893 | // hero h1s — a tiny tan label directly above any h1 is the |
| 894 | // antipattern regardless of how big the h1 ends up. |
| 895 | if (!siblingTag) return []; |
| 896 | // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual |
| 897 | // headings) — never an eyebrow. |
| 898 | if (HEADING_TAGS.has(siblingTag)) return []; |
| 899 | |
| 900 | const text = (siblingText || '').trim(); |
| 901 | if (text.length < 2 || text.length > 60) return []; |
| 902 | if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return []; |
| 903 | |
| 904 | // Branch A: classic tracked-uppercase eyebrow. |
| 905 | const isUppercased = siblingTextTransform === 'uppercase' |
| 906 | || (/[A-Z]/.test(text) && !/[a-z]/.test(text)); |
| 907 | const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6; |
| 908 | |
| 909 | // Branch B: modern accent-bold eyebrow — sentence case, low |
| 910 | // tracking, but bold + accent-colored. The style choices changed; |
| 911 | // the pattern is the same kicker-above-headline anti-pattern. |
| 912 | const weight = Number(siblingFontWeight) || 400; |
| 913 | const isAccentBold = weight >= 700 && isAccentColor(siblingColor || ''); |
| 914 | |
| 915 | if (!isClassicTracked && !isAccentBold) return []; |
| 916 | |
| 917 | const headingTextSnippet = (headingText || '').trim().slice(0, 60); |
| 918 | const eyebrowSnippet = text.slice(0, 40); |
| 919 | const style = isClassicTracked ? 'tracked-caps' : 'accent-bold'; |
| 920 | return [{ |
| 921 | id: 'hero-eyebrow-chip', |
| 922 | snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`, |
| 923 | }]; |
| 924 | } |
| 925 | |
| 926 | function checkRepeatedSectionKickers(opts) { |
| 927 | const { candidates, minCount = 3 } = opts; |
| 928 | if (!Array.isArray(candidates) || candidates.length < minCount) return []; |
| 929 | return candidates.map(candidate => ({ |
| 930 | id: 'repeated-section-kickers', |
| 931 | snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`, |
| 932 | })); |
| 933 | } |
| 934 | |
| 935 | const LAYOUT_TRANSITION_PROPS = new Set([ |
| 936 | 'width', 'height', 'padding', 'margin', |
| 937 | 'max-height', 'max-width', 'min-height', 'min-width', |
| 938 | 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', |
| 939 | 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', |
| 940 | ]); |
| 941 | |
| 942 | function checkMotion(opts) { |
| 943 | const { tag, transitionProperty, animationName, timingFunctions, classList } = opts; |
| 944 | if (SAFE_TAGS.has(tag)) return []; |
| 945 | const findings = []; |
| 946 | |
| 947 | // --- Bounce/elastic easing --- |
| 948 | if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) { |
| 949 | findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` }); |
| 950 | } |
| 951 | if (classList && /\banimate-bounce\b/.test(classList)) { |
| 952 | findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' }); |
| 953 | } |
| 954 | |
| 955 | // Check timing functions for overshoot cubic-bezier (y values outside [0, 1]) |
| 956 | if (timingFunctions) { |
| 957 | const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; |
| 958 | let m; |
| 959 | while ((m = bezierRe.exec(timingFunctions)) !== null) { |
| 960 | const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]); |
| 961 | if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { |
| 962 | findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` }); |
| 963 | break; |
| 964 | } |
| 965 | } |
| 966 | } |
| 967 | |
| 968 | // --- Layout property transition --- |
| 969 | if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') { |
| 970 | const props = transitionProperty.split(',').map(p => p.trim().toLowerCase()); |
| 971 | const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p)); |
| 972 | if (layoutFound.length > 0) { |
| 973 | findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` }); |
| 974 | } |
| 975 | } |
| 976 | |
| 977 | return findings; |
| 978 | } |
| 979 | |
| 980 | function checkGlow(opts) { |
| 981 | const { boxShadow, effectiveBg } = opts; |
| 982 | if (!boxShadow || boxShadow === 'none') return []; |
| 983 | if (!effectiveBg) return []; |
| 984 | |
| 985 | // Only flag on dark backgrounds (luminance < 0.1) |
| 986 | const bgLum = relativeLuminance(effectiveBg); |
| 987 | if (bgLum >= 0.1) return []; |
| 988 | |
| 989 | // Split multiple shadows (commas not inside parentheses) |
| 990 | const parts = boxShadow.split(/,(?![^(]*\))/); |
| 991 | for (const shadow of parts) { |
| 992 | const colorMatch = shadow.match(/rgba?\([^)]+\)/); |
| 993 | if (!colorMatch) continue; |
| 994 | const color = parseRgb(colorMatch[0]); |
| 995 | if (!color || !hasChroma(color, 30)) continue; |
| 996 | |
| 997 | // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]" |
| 998 | const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length); |
| 999 | const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0])); |
| 1000 | const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)] |
| 1001 | .map(m => parseFloat(m[1])); |
| 1002 | |
| 1003 | // Third value is blur (offset-x, offset-y, blur, [spread]) |
| 1004 | if (pxVals.length >= 3 && pxVals[2] > 4) { |
| 1005 | return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }]; |
| 1006 | } |
| 1007 | } |
| 1008 | |
| 1009 | return []; |
| 1010 | } |
| 1011 | |
| 1012 | /** |
| 1013 | * Regex-on-HTML checks shared between browser and Node page-level detection. |
| 1014 | * These don't need DOM access, just the raw HTML string. |
| 1015 | */ |
| 1016 | function checkHtmlPatterns(html) { |
| 1017 | const findings = []; |
| 1018 | |
| 1019 | // --- Color --- |
| 1020 | |
| 1021 | // AI color palette: purple/violet |
| 1022 | const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi; |
| 1023 | if (purpleHexRe.test(html)) { |
| 1024 | const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi; |
| 1025 | if (purpleTextRe.test(html)) { |
| 1026 | findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' }); |
| 1027 | } |
| 1028 | } |
| 1029 | |
| 1030 | // Gradient text (background-clip: text + gradient) |
| 1031 | const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi; |
| 1032 | let gm; |
| 1033 | while ((gm = gradientRe.exec(html)) !== null) { |
| 1034 | const start = Math.max(0, gm.index - 200); |
| 1035 | const context = html.substring(start, gm.index + gm[0].length + 200); |
| 1036 | if (/gradient/i.test(context)) { |
| 1037 | findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' }); |
| 1038 | break; |
| 1039 | } |
| 1040 | } |
| 1041 | if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) { |
| 1042 | findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' }); |
| 1043 | } |
| 1044 | |
| 1045 | // --- Layout --- |
| 1046 | |
| 1047 | // Monotonous spacing |
| 1048 | const spacingValues = []; |
| 1049 | const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi; |
| 1050 | let sm; |
| 1051 | while ((sm = spacingRe.exec(html)) !== null) { |
| 1052 | const v = parseInt(sm[1], 10); |
| 1053 | if (v > 0 && v < 200) spacingValues.push(v); |
| 1054 | } |
| 1055 | const gapRe = /gap\s*:\s*(\d+)px/gi; |
| 1056 | while ((sm = gapRe.exec(html)) !== null) { |
| 1057 | spacingValues.push(parseInt(sm[1], 10)); |
| 1058 | } |
| 1059 | const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g; |
| 1060 | while ((sm = twSpaceRe.exec(html)) !== null) { |
| 1061 | spacingValues.push(parseInt(sm[1], 10) * 4); |
| 1062 | } |
| 1063 | const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi; |
| 1064 | while ((sm = remSpacingRe.exec(html)) !== null) { |
| 1065 | const v = Math.round(parseFloat(sm[1]) * 16); |
| 1066 | if (v > 0 && v < 200) spacingValues.push(v); |
| 1067 | } |
| 1068 | const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4); |
| 1069 | if (roundedSpacing.length >= 10) { |
| 1070 | const counts = {}; |
| 1071 | for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1; |
| 1072 | const maxCount = Math.max(...Object.values(counts)); |
| 1073 | const dominantPct = maxCount / roundedSpacing.length; |
| 1074 | const unique = [...new Set(roundedSpacing)].filter(v => v > 0); |
| 1075 | if (dominantPct > 0.6 && unique.length <= 3) { |
| 1076 | const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]; |
| 1077 | findings.push({ |
| 1078 | id: 'monotonous-spacing', |
| 1079 | snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`, |
| 1080 | }); |
| 1081 | } |
| 1082 | } |
| 1083 | |
| 1084 | // --- Motion --- |
| 1085 | |
| 1086 | // Bounce/elastic animation names |
| 1087 | const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi; |
| 1088 | if (bounceRe.test(html)) { |
| 1089 | findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' }); |
| 1090 | } |
| 1091 | |
| 1092 | // Overshoot cubic-bezier |
| 1093 | const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g; |
| 1094 | let bm; |
| 1095 | while ((bm = bezierRe.exec(html)) !== null) { |
| 1096 | const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]); |
| 1097 | if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) { |
| 1098 | findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` }); |
| 1099 | break; |
| 1100 | } |
| 1101 | } |
| 1102 | |
| 1103 | // Layout property transitions |
| 1104 | const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi; |
| 1105 | let tm; |
| 1106 | while ((tm = transRe.exec(html)) !== null) { |
| 1107 | const val = tm[1].toLowerCase(); |
| 1108 | if (/\ball\b/.test(val)) continue; |
| 1109 | const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi); |
| 1110 | if (found) { |
| 1111 | findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` }); |
| 1112 | break; |
| 1113 | } |
| 1114 | } |
| 1115 | |
| 1116 | // --- Dark glow --- |
| 1117 | |
| 1118 | const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi; |
| 1119 | const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/; |
| 1120 | if (darkBgRe.test(html) || twDarkBg.test(html)) { |
| 1121 | const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi; |
| 1122 | let shm; |
| 1123 | while ((shm = shadowRe.exec(html)) !== null) { |
| 1124 | const val = shm[1]; |
| 1125 | const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); |
| 1126 | if (!colorMatch) continue; |
| 1127 | const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]]; |
| 1128 | if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; |
| 1129 | const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2])); |
| 1130 | if (pxVals.length >= 3 && pxVals[2] > 4) { |
| 1131 | findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` }); |
| 1132 | break; |
| 1133 | } |
| 1134 | } |
| 1135 | } |
| 1136 | |
| 1137 | // --- Provider tells (gated): repeating-gradient stripes (GPT) --- |
| 1138 | if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) { |
| 1139 | findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' }); |
| 1140 | } |
| 1141 | |
| 1142 | // --- Provider tells (gated): "X theater" framing copy (GPT) --- |
| 1143 | // Lives here (regex-on-HTML) rather than in the text-content analyzers so it |
| 1144 | // runs in the bundled browser path too, not just the CLI/static path. |
| 1145 | { |
| 1146 | const bodyText = html |
| 1147 | .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ') |
| 1148 | .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ') |
| 1149 | .replace(/<[^>]+>/g, ' '); |
| 1150 | const tm = /\b(\w+)\s+theater\b/i.exec(bodyText); |
| 1151 | if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` }); |
| 1152 | } |
| 1153 | |
| 1154 | // --- Provider tells (gated): image hover transform (Gemini) --- |
| 1155 | // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale / |
| 1156 | // hover:rotate / hover:translate utility on an <img>. Each distinct |
| 1157 | // mechanism is its own finding. |
| 1158 | const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i; |
| 1159 | if (imgHoverCss.test(html)) { |
| 1160 | findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' }); |
| 1161 | } |
| 1162 | const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi; |
| 1163 | let im; |
| 1164 | while ((im = imgTagRe.exec(html)) !== null) { |
| 1165 | if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) { |
| 1166 | findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' }); |
| 1167 | } |
| 1168 | } |
| 1169 | |
| 1170 | return findings; |
| 1171 | } |
| 1172 | |
| 1173 | // ─── Section 4: resolveBackground (unified) ───────────────────────────────── |
| 1174 | |
| 1175 | // Read the element's own background color, computed-style first, with a |
| 1176 | // jsdom-friendly fallback that parses the inline `background:` shorthand |
| 1177 | // from the raw style attribute. jsdom (~v29) does not decompose the |
| 1178 | // shorthand into `backgroundColor`, so without this fallback the CLI silently |
| 1179 | // returns null for any element styled via `background: rgb(...)` or |
| 1180 | // `background: #abc`. Real browsers always decompose, so the fallback is |
| 1181 | // a no-op there. |
| 1182 | function readOwnBackgroundColor(el, computedStyle) { |
| 1183 | const bg = parseRgb(computedStyle.backgroundColor); |
| 1184 | if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; |
| 1185 | const rawStyle = el.getAttribute?.('style') || ''; |
| 1186 | const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); |
| 1187 | const inlineBg = bgMatch ? bgMatch[1].trim() : ''; |
| 1188 | if (!inlineBg) return bg; |
| 1189 | if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg; |
| 1190 | const fromRgb = parseRgb(inlineBg); |
| 1191 | if (fromRgb) return fromRgb; |
| 1192 | const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i); |
| 1193 | if (hexMatch) { |
| 1194 | const h = hexMatch[1]; |
| 1195 | if (h.length === 6) { |
| 1196 | return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 }; |
| 1197 | } |
| 1198 | return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 }; |
| 1199 | } |
| 1200 | return bg; |
| 1201 | } |
| 1202 | |
| 1203 | function resolveBackground(el, win, customPropMap) { |
| 1204 | let current = el; |
| 1205 | while (current && current.nodeType === 1) { |
| 1206 | const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); |
| 1207 | const bgImage = style.backgroundImage || ''; |
| 1208 | const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage)); |
| 1209 | |
| 1210 | // Try the solid bg-color FIRST. If the element has both a solid color |
| 1211 | // and a gradient/url overlay (a common pattern: `background: var(--paper) |
| 1212 | // radial-gradient(...)` for paper-grain texture), the solid color is the |
| 1213 | // dominant visible surface for contrast purposes; the overlay is |
| 1214 | // decorative. The old behavior bailed on any gradient ancestor, which |
| 1215 | // caused massive false-positive contrast findings on grain-textured |
| 1216 | // body backgrounds. |
| 1217 | let bg = parseRgb(style.backgroundColor); |
| 1218 | if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { |
| 1219 | // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve |
| 1220 | // through customPropMap so Tailwind v4 color tokens become RGB. |
| 1221 | if (customPropMap) { |
| 1222 | bg = parseColorResolved(style.backgroundColor, customPropMap); |
| 1223 | } |
| 1224 | if (!bg || bg.a < 0.1) { |
| 1225 | // Inline-style fallback. jsdom doesn't decompose background |
| 1226 | // shorthand, so colors set via inline style are otherwise invisible. |
| 1227 | const rawStyle = current.getAttribute?.('style') || ''; |
| 1228 | const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); |
| 1229 | const inlineBg = bgMatch ? bgMatch[1].trim() : ''; |
| 1230 | if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) { |
| 1231 | bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg); |
| 1232 | } |
| 1233 | } |
| 1234 | } |
| 1235 | |
| 1236 | if (bg && bg.a > 0.1) { |
| 1237 | if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg; |
| 1238 | } |
| 1239 | // No solid bg-color at this level. If THIS level has a gradient/url |
| 1240 | // with no underlying solid color we can read: |
| 1241 | // • on body/html: assume white. Body-level gradients are almost |
| 1242 | // always decorative texture (paper grain, noise) on top of a |
| 1243 | // solid bg-color the page set via `background: var(--paper)` |
| 1244 | // shorthand — which jsdom can't decompose into bg-color. The |
| 1245 | // downstream gradient-stops fallback path produces catastrophic |
| 1246 | // false positives in this case (gradient noise stops have |
| 1247 | // accidental browns/blacks that look like card backgrounds). |
| 1248 | // • on other elements: bail to null and let the caller fall back |
| 1249 | // to gradient stops (gradient buttons / hero sections are real |
| 1250 | // bgs worth checking against). |
| 1251 | if (hasGradientOrUrl) { |
| 1252 | if (current.tagName === 'BODY' || current.tagName === 'HTML') { |
| 1253 | return { r: 255, g: 255, b: 255, a: 1 }; |
| 1254 | } |
| 1255 | return null; |
| 1256 | } |
| 1257 | current = current.parentElement; |
| 1258 | } |
| 1259 | return { r: 255, g: 255, b: 255 }; |
| 1260 | } |
| 1261 | |
| 1262 | // Walk parents looking for a gradient background and return its color stops. |
| 1263 | // Used as a fallback when resolveBackground() returns null because the |
| 1264 | // effective background is a gradient (no single solid color to compare against). |
| 1265 | function resolveGradientStops(el, win) { |
| 1266 | let current = el; |
| 1267 | while (current && current.nodeType === 1) { |
| 1268 | const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); |
| 1269 | const bgImage = style.backgroundImage || ''; |
| 1270 | if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { |
| 1271 | const stops = parseGradientColors(bgImage); |
| 1272 | if (stops.length > 0) return stops; |
| 1273 | } |
| 1274 | if (!DETECTOR_IS_BROWSER) { |
| 1275 | // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style |
| 1276 | const rawStyle = current.getAttribute?.('style') || ''; |
| 1277 | const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); |
| 1278 | if (bgMatch && /gradient/i.test(bgMatch[1])) { |
| 1279 | const stops = parseGradientColors(bgMatch[1]); |
| 1280 | if (stops.length > 0) return stops; |
| 1281 | } |
| 1282 | } |
| 1283 | current = current.parentElement; |
| 1284 | } |
| 1285 | return null; |
| 1286 | } |
| 1287 | |
| 1288 | // Parse a single CSS length token to pixels. Accepts "12px", "50%", a |
| 1289 | // shorthand like "12px 4px" (uses the first value), or empty / null. |
| 1290 | // Returns the pixel value, or null when the input is unparseable. |
| 1291 | // Percentages convert against `widthPx` when one is supplied. Without a |
| 1292 | // usable width (jsdom returns "auto" for many real-world elements, |
| 1293 | // which parseFloat collapses to 0), fall back to the raw percentage |
| 1294 | // number so callers gating on `> 0` (border-accent-on-rounded, |
| 1295 | // isCardLike's hasRadius) still see a positive value, matching the |
| 1296 | // original parseFloat("50%") === 50 behavior. |
| 1297 | function parseRadiusToPx(value, widthPx) { |
| 1298 | if (!value || typeof value !== 'string') return null; |
| 1299 | const trimmed = value.trim(); |
| 1300 | if (!trimmed) return null; |
| 1301 | const first = trimmed.split(/\s+/)[0]; |
| 1302 | const num = parseFloat(first); |
| 1303 | if (Number.isNaN(num)) return null; |
| 1304 | if (/%$/.test(first)) { |
| 1305 | if (widthPx && widthPx > 0) return (num / 100) * widthPx; |
| 1306 | return num; |
| 1307 | } |
| 1308 | return num; |
| 1309 | } |
| 1310 | |
| 1311 | function resolveBorderRadiusPx(el, style, widthPx, win) { |
| 1312 | const fromComputed = parseRadiusToPx(style.borderRadius, widthPx); |
| 1313 | if (fromComputed !== null) return fromComputed; |
| 1314 | return 0; |
| 1315 | } |
| 1316 | |
| 1317 | // ─── Section 5: Element Adapters ──────────────────────────────────────────── |
| 1318 | |
| 1319 | // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM |
| 1320 | |
| 1321 | function checkElementBordersDOM(el) { |
| 1322 | const tag = el.tagName.toLowerCase(); |
| 1323 | if (BORDER_SAFE_TAGS.has(tag)) return []; |
| 1324 | const rect = el.getBoundingClientRect(); |
| 1325 | if (rect.width < 20 || rect.height < 20) return []; |
| 1326 | const style = getComputedStyle(el); |
| 1327 | const sides = ['Top', 'Right', 'Bottom', 'Left']; |
| 1328 | const widths = {}, colors = {}; |
| 1329 | for (const s of sides) { |
| 1330 | widths[s] = parseFloat(style[`border${s}Width`]) || 0; |
| 1331 | colors[s] = style[`border${s}Color`] || ''; |
| 1332 | } |
| 1333 | return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0); |
| 1334 | } |
| 1335 | |
| 1336 | function checkElementColorsDOM(el) { |
| 1337 | const tag = el.tagName.toLowerCase(); |
| 1338 | // No early SAFE_TAGS bail here — checkColors() does its own gating that |
| 1339 | // includes the styled-button exception for <a> / <button> with their own |
| 1340 | // opaque background. Bailing here would prevent that exception from firing. |
| 1341 | const rect = el.getBoundingClientRect(); |
| 1342 | if (rect.width < 10 || rect.height < 10) return []; |
| 1343 | const style = getComputedStyle(el); |
| 1344 | const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); |
| 1345 | const hasDirectText = directText.trim().length > 0; |
| 1346 | const effectiveBg = resolveBackground(el); |
| 1347 | return checkColors({ |
| 1348 | tag, |
| 1349 | textColor: parseRgb(style.color), |
| 1350 | bgColor: readOwnBackgroundColor(el, style), |
| 1351 | effectiveBg, |
| 1352 | effectiveBgStops: effectiveBg ? null : resolveGradientStops(el), |
| 1353 | fontSize: parseFloat(style.fontSize) || 16, |
| 1354 | fontWeight: parseInt(style.fontWeight) || 400, |
| 1355 | hasDirectText, |
| 1356 | isEmojiOnly: isEmojiOnlyText(directText), |
| 1357 | bgClip: style.webkitBackgroundClip || style.backgroundClip || '', |
| 1358 | bgImage: style.backgroundImage || '', |
| 1359 | classList: el.getAttribute('class') || '', |
| 1360 | }); |
| 1361 | } |
| 1362 | |
| 1363 | function checkElementIconTileDOM(el) { |
| 1364 | const tag = el.tagName.toLowerCase(); |
| 1365 | if (!HEADING_TAGS.has(tag)) return []; |
| 1366 | const sibling = el.previousElementSibling; |
| 1367 | if (!sibling) return []; |
| 1368 | |
| 1369 | const sibRect = sibling.getBoundingClientRect(); |
| 1370 | const headRect = el.getBoundingClientRect(); |
| 1371 | const sibStyle = getComputedStyle(sibling); |
| 1372 | |
| 1373 | // The tile may either contain an <svg>/<i> icon child, OR the tile itself |
| 1374 | // may contain an emoji/symbol character directly as its only text content |
| 1375 | // (the "card-icon" pattern from many AI-generated demos). |
| 1376 | const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); |
| 1377 | const iconRect = iconChild?.getBoundingClientRect(); |
| 1378 | const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); |
| 1379 | const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); |
| 1380 | |
| 1381 | return checkIconTile({ |
| 1382 | headingTag: tag, |
| 1383 | headingText: el.textContent || '', |
| 1384 | headingTop: headRect.top, |
| 1385 | siblingTag: sibling.tagName.toLowerCase(), |
| 1386 | siblingWidth: sibRect.width, |
| 1387 | siblingHeight: sibRect.height, |
| 1388 | siblingBottom: sibRect.bottom, |
| 1389 | siblingBgColor: parseRgb(sibStyle.backgroundColor), |
| 1390 | siblingBgImage: sibStyle.backgroundImage || '', |
| 1391 | siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, |
| 1392 | siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0, |
| 1393 | hasIconChild: !!iconChild || hasInlineEmojiIcon, |
| 1394 | iconChildWidth: iconRect?.width || 0, |
| 1395 | }); |
| 1396 | } |
| 1397 | |
| 1398 | function checkElementItalicSerifDOM(el) { |
| 1399 | const tag = el.tagName.toLowerCase(); |
| 1400 | if (tag !== 'h1' && tag !== 'h2') return []; |
| 1401 | const style = getComputedStyle(el); |
| 1402 | return checkItalicSerif({ |
| 1403 | tag, |
| 1404 | fontStyle: style.fontStyle || '', |
| 1405 | fontFamily: style.fontFamily || '', |
| 1406 | fontSize: parseFloat(style.fontSize) || 0, |
| 1407 | headingText: el.textContent || '', |
| 1408 | }); |
| 1409 | } |
| 1410 | |
| 1411 | function checkElementHeroEyebrowDOM(el) { |
| 1412 | const tag = el.tagName.toLowerCase(); |
| 1413 | if (tag !== 'h1') return []; |
| 1414 | const sibling = el.previousElementSibling; |
| 1415 | if (!sibling) return []; |
| 1416 | const headStyle = getComputedStyle(el); |
| 1417 | const sibStyle = getComputedStyle(sibling); |
| 1418 | return checkHeroEyebrow({ |
| 1419 | headingTag: tag, |
| 1420 | headingText: el.textContent || '', |
| 1421 | headingFontSize: parseFloat(headStyle.fontSize) || 0, |
| 1422 | siblingTag: sibling.tagName.toLowerCase(), |
| 1423 | siblingText: sibling.textContent || '', |
| 1424 | siblingTextTransform: sibStyle.textTransform || '', |
| 1425 | siblingFontSize: parseFloat(sibStyle.fontSize) || 0, |
| 1426 | siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0, |
| 1427 | siblingFontWeight: sibStyle.fontWeight || '', |
| 1428 | siblingColor: sibStyle.color || '', |
| 1429 | }); |
| 1430 | } |
| 1431 | |
| 1432 | // Build a map of CSS custom properties declared on :root / :host / html. |
| 1433 | // Used to resolve var(--X) refs that jsdom returns verbatim in |
| 1434 | // getComputedStyle. Tailwind v4 routes every utility class through |
| 1435 | // CSS vars (font-weight: var(--font-weight-bold), font-size: |
| 1436 | // var(--text-xs), letter-spacing: var(--tracking-widest)), so without |
| 1437 | // resolution every style-based check silently fails on Tailwind v4 |
| 1438 | // builds — the values come back as literal "var(--font-weight-bold)" |
| 1439 | // strings and parseFloat returns NaN. |
| 1440 | function buildCustomPropMap(document) { |
| 1441 | const map = new Map(); |
| 1442 | let sheets; |
| 1443 | try { sheets = Array.from(document.styleSheets || []); } |
| 1444 | catch { return map; } |
| 1445 | for (const sheet of sheets) { |
| 1446 | let rules; |
| 1447 | try { rules = Array.from(sheet.cssRules || []); } |
| 1448 | catch { continue; } |
| 1449 | for (const rule of rules) { |
| 1450 | // Style rules only (type 1). Walk @media / @supports if present. |
| 1451 | if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) { |
| 1452 | try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ } |
| 1453 | continue; |
| 1454 | } |
| 1455 | if (rule.type !== 1 /* STYLE_RULE */) continue; |
| 1456 | const sel = rule.selectorText || ''; |
| 1457 | if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue; |
| 1458 | const style = rule.style; |
| 1459 | if (!style) continue; |
| 1460 | for (let i = 0; i < style.length; i++) { |
| 1461 | const prop = style[i]; |
| 1462 | if (!prop || !prop.startsWith('--')) continue; |
| 1463 | const val = style.getPropertyValue(prop).trim(); |
| 1464 | if (val) map.set(prop, val); |
| 1465 | } |
| 1466 | } |
| 1467 | } |
| 1468 | return map; |
| 1469 | } |
| 1470 | |
| 1471 | // Resolve var(--X[, fallback]) refs in a computed-style value string. |
| 1472 | // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns |
| 1473 | // the original string when no refs are present or the chain doesn't |
| 1474 | // resolve. Safe to call on already-resolved values. |
| 1475 | function resolveVarRefs(raw, customPropMap, depth = 0) { |
| 1476 | if (typeof raw !== 'string' || !raw.includes('var(')) return raw; |
| 1477 | if (depth > 8) return raw; |
| 1478 | return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => { |
| 1479 | const v = customPropMap.get(name); |
| 1480 | if (v != null) return resolveVarRefs(v, customPropMap, depth + 1); |
| 1481 | return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m; |
| 1482 | }); |
| 1483 | } |
| 1484 | |
| 1485 | // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %), |
| 1486 | // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255. |
| 1487 | // Needed because jsdom doesn't compute oklch() values — getComputedStyle |
| 1488 | // returns the literal "oklch(...)" string. Without this, the entire |
| 1489 | // Tailwind v4 color palette (which is OKLCH-based) is invisible to the |
| 1490 | // detector's contrast / color checks. |
| 1491 | function oklchToRgb(L, C, H) { |
| 1492 | const hRad = (H * Math.PI) / 180; |
| 1493 | const a = C * Math.cos(hRad); |
| 1494 | const b = C * Math.sin(hRad); |
| 1495 | const l_ = L + 0.3963377774 * a + 0.2158037573 * b; |
| 1496 | const m_ = L - 0.1055613458 * a - 0.0638541728 * b; |
| 1497 | const s_ = L - 0.0894841775 * a - 1.2914855480 * b; |
| 1498 | const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_; |
| 1499 | const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; |
| 1500 | const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; |
| 1501 | const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc; |
| 1502 | const enc = (x) => { |
| 1503 | const c = Math.max(0, Math.min(1, x)); |
| 1504 | return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; |
| 1505 | }; |
| 1506 | return { |
| 1507 | r: Math.round(enc(rLin) * 255), |
| 1508 | g: Math.round(enc(gLin) * 255), |
| 1509 | b: Math.round(enc(bLin) * 255), |
| 1510 | a: 1, |
| 1511 | }; |
| 1512 | } |
| 1513 | |
| 1514 | // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match. |
| 1515 | // Use this when the input might be any CSS color form; use plain parseRgb |
| 1516 | // when you only expect computed rgb() values from real browsers. |
| 1517 | function parseAnyColor(s) { |
| 1518 | if (!s || typeof s !== 'string') return null; |
| 1519 | const str = s.trim(); |
| 1520 | if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null; |
| 1521 | let m; |
| 1522 | m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/); |
| 1523 | if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 }; |
| 1524 | m = str.match(/^#([0-9a-f]{3,8})$/i); |
| 1525 | if (m) { |
| 1526 | const h = m[1]; |
| 1527 | if (h.length === 3 || h.length === 4) { |
| 1528 | return { |
| 1529 | r: parseInt(h[0] + h[0], 16), |
| 1530 | g: parseInt(h[1] + h[1], 16), |
| 1531 | b: parseInt(h[2] + h[2], 16), |
| 1532 | a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1, |
| 1533 | }; |
| 1534 | } |
| 1535 | if (h.length === 6 || h.length === 8) { |
| 1536 | return { |
| 1537 | r: parseInt(h.slice(0, 2), 16), |
| 1538 | g: parseInt(h.slice(2, 4), 16), |
| 1539 | b: parseInt(h.slice(4, 6), 16), |
| 1540 | a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1, |
| 1541 | }; |
| 1542 | } |
| 1543 | } |
| 1544 | // OKLCH parser. Tailwind v4's CSS minifier squishes the space after |
| 1545 | // `%` ("21.5%.02 50"), so the separator between L and C may be absent. |
| 1546 | // Match L (with optional %), then C and H separated permissively. |
| 1547 | m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i); |
| 1548 | if (m) { |
| 1549 | const Lnum = parseFloat(m[1]); |
| 1550 | const L = m[2] === '%' ? Lnum / 100 : Lnum; |
| 1551 | return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4])); |
| 1552 | } |
| 1553 | return null; |
| 1554 | } |
| 1555 | |
| 1556 | // Resolve var() refs in a color string (via customPropMap), then parse. |
| 1557 | // Returns null on any failure. Used in jsdom-mode paths where |
| 1558 | // getComputedStyle returns literal "var(--X)" or "oklch(...)" strings. |
| 1559 | function parseColorResolved(str, customPropMap) { |
| 1560 | if (!str) return null; |
| 1561 | const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str; |
| 1562 | return parseAnyColor(resolved); |
| 1563 | } |
| 1564 | |
| 1565 | const REPEATED_KICKER_SKIP_SELECTOR = [ |
| 1566 | 'nav', |
| 1567 | 'form', |
| 1568 | 'table', |
| 1569 | 'thead', |
| 1570 | 'tbody', |
| 1571 | 'tfoot', |
| 1572 | 'figure', |
| 1573 | 'figcaption', |
| 1574 | 'ol', |
| 1575 | 'ul', |
| 1576 | 'li', |
| 1577 | '[role="navigation"]', |
| 1578 | '[aria-label*="breadcrumb" i]', |
| 1579 | '[class*="breadcrumb" i]', |
| 1580 | '[data-impeccable-allow-kickers]', |
| 1581 | ].join(','); |
| 1582 | |
| 1583 | function cleanInlineText(el) { |
| 1584 | return [...el.childNodes] |
| 1585 | .filter(n => n.nodeType === 3) |
| 1586 | .map(n => n.textContent) |
| 1587 | .join(' ') |
| 1588 | .replace(/\s+/g, ' ') |
| 1589 | .trim(); |
| 1590 | } |
| 1591 | |
| 1592 | function isRepeatedKickerCandidate(opts) { |
| 1593 | const { |
| 1594 | headingTag, |
| 1595 | headingText, |
| 1596 | headingFontSize, |
| 1597 | kickerTag, |
| 1598 | kickerText, |
| 1599 | kickerTextTransform, |
| 1600 | kickerFontSize, |
| 1601 | kickerLetterSpacing, |
| 1602 | } = opts; |
| 1603 | if (!['h2', 'h3', 'h4'].includes(headingTag)) return false; |
| 1604 | if (!headingText || headingText.length < 3) return false; |
| 1605 | if (!(headingFontSize >= 20)) return false; |
| 1606 | if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false; |
| 1607 | if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false; |
| 1608 | if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false; |
| 1609 | if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false; |
| 1610 | |
| 1611 | const isUppercased = kickerTextTransform === 'uppercase' |
| 1612 | || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText)); |
| 1613 | if (!isUppercased) return false; |
| 1614 | if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false; |
| 1615 | const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08); |
| 1616 | if (!(kickerLetterSpacing >= minTrackedSpacing)) return false; |
| 1617 | return true; |
| 1618 | } |
| 1619 | |
| 1620 | function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) { |
| 1621 | const candidates = []; |
| 1622 | for (const heading of doc.querySelectorAll('h2, h3, h4')) { |
| 1623 | if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; |
| 1624 | const kicker = heading.previousElementSibling; |
| 1625 | if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue; |
| 1626 | |
| 1627 | const headingStyle = getStyle(heading); |
| 1628 | const kickerStyle = getStyle(kicker); |
| 1629 | const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim(); |
| 1630 | const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim(); |
| 1631 | const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0; |
| 1632 | const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0; |
| 1633 | const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize); |
| 1634 | |
| 1635 | if (!isRepeatedKickerCandidate({ |
| 1636 | headingTag: heading.tagName.toLowerCase(), |
| 1637 | headingText, |
| 1638 | headingFontSize, |
| 1639 | kickerTag: kicker.tagName.toLowerCase(), |
| 1640 | kickerText, |
| 1641 | kickerTextTransform: kickerStyle.textTransform || '', |
| 1642 | kickerFontSize, |
| 1643 | kickerLetterSpacing, |
| 1644 | })) { |
| 1645 | continue; |
| 1646 | } |
| 1647 | |
| 1648 | candidates.push({ |
| 1649 | headingTag: heading.tagName.toLowerCase(), |
| 1650 | headingText: headingText.replace(/^"|"$/g, '').slice(0, 60), |
| 1651 | kickerText: kickerText.slice(0, 40), |
| 1652 | }); |
| 1653 | } |
| 1654 | return candidates; |
| 1655 | } |
| 1656 | |
| 1657 | function checkRepeatedSectionKickersDOM() { |
| 1658 | const candidates = collectRepeatedSectionKickerCandidates( |
| 1659 | document, |
| 1660 | (el) => getComputedStyle(el), |
| 1661 | (value, fontSize) => resolveLengthPx(value, fontSize) || 0, |
| 1662 | ); |
| 1663 | return checkRepeatedSectionKickers({ candidates }); |
| 1664 | } |
| 1665 | |
| 1666 | function checkElementMotionDOM(el) { |
| 1667 | const tag = el.tagName.toLowerCase(); |
| 1668 | if (SAFE_TAGS.has(tag)) return []; |
| 1669 | const style = getComputedStyle(el); |
| 1670 | return checkMotion({ |
| 1671 | tag, |
| 1672 | transitionProperty: style.transitionProperty || '', |
| 1673 | animationName: style.animationName || '', |
| 1674 | timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), |
| 1675 | classList: el.getAttribute('class') || '', |
| 1676 | }); |
| 1677 | } |
| 1678 | |
| 1679 | function checkElementGlowDOM(el) { |
| 1680 | const tag = el.tagName.toLowerCase(); |
| 1681 | const style = getComputedStyle(el); |
| 1682 | if (!style.boxShadow || style.boxShadow === 'none') return []; |
| 1683 | // Use parent's background — glow radiates outward, so the surrounding context matters |
| 1684 | // If resolveBackground returns null (gradient), try to infer from the gradient colors |
| 1685 | let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el); |
| 1686 | if (!parentBg) { |
| 1687 | // Gradient background — sample its colors to determine if it's dark |
| 1688 | let cur = el.parentElement; |
| 1689 | while (cur && cur.nodeType === 1) { |
| 1690 | const bgImage = getComputedStyle(cur).backgroundImage || ''; |
| 1691 | const gradColors = parseGradientColors(bgImage); |
| 1692 | if (gradColors.length > 0) { |
| 1693 | // Average the gradient colors |
| 1694 | const avg = { r: 0, g: 0, b: 0 }; |
| 1695 | for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } |
| 1696 | avg.r = Math.round(avg.r / gradColors.length); |
| 1697 | avg.g = Math.round(avg.g / gradColors.length); |
| 1698 | avg.b = Math.round(avg.b / gradColors.length); |
| 1699 | parentBg = avg; |
| 1700 | break; |
| 1701 | } |
| 1702 | cur = cur.parentElement; |
| 1703 | } |
| 1704 | } |
| 1705 | return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg }); |
| 1706 | } |
| 1707 | |
| 1708 | function checkElementAIPaletteDOM(el) { |
| 1709 | const style = getComputedStyle(el); |
| 1710 | const findings = []; |
| 1711 | |
| 1712 | // Check gradient backgrounds for purple/violet or cyan |
| 1713 | const bgImage = style.backgroundImage || ''; |
| 1714 | const gradColors = parseGradientColors(bgImage); |
| 1715 | for (const c of gradColors) { |
| 1716 | if (hasChroma(c, 50)) { |
| 1717 | const hue = getHue(c); |
| 1718 | if (hue >= 260 && hue <= 310) { |
| 1719 | findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' }); |
| 1720 | break; |
| 1721 | } |
| 1722 | if (hue >= 160 && hue <= 200) { |
| 1723 | findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' }); |
| 1724 | break; |
| 1725 | } |
| 1726 | } |
| 1727 | } |
| 1728 | |
| 1729 | // Check for neon text (vivid cyan/purple color on dark background) |
| 1730 | const textColor = parseRgb(style.color); |
| 1731 | if (textColor && hasChroma(textColor, 80)) { |
| 1732 | const hue = getHue(textColor); |
| 1733 | const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310); |
| 1734 | if (isAIPalette) { |
| 1735 | const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null; |
| 1736 | // Also check gradient parents |
| 1737 | let effectiveBg = parentBg; |
| 1738 | if (!effectiveBg) { |
| 1739 | let cur = el.parentElement; |
| 1740 | while (cur && cur.nodeType === 1) { |
| 1741 | const gi = getComputedStyle(cur).backgroundImage || ''; |
| 1742 | const gc = parseGradientColors(gi); |
| 1743 | if (gc.length > 0) { |
| 1744 | const avg = { r: 0, g: 0, b: 0 }; |
| 1745 | for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; } |
| 1746 | avg.r = Math.round(avg.r / gc.length); |
| 1747 | avg.g = Math.round(avg.g / gc.length); |
| 1748 | avg.b = Math.round(avg.b / gc.length); |
| 1749 | effectiveBg = avg; |
| 1750 | break; |
| 1751 | } |
| 1752 | cur = cur.parentElement; |
| 1753 | } |
| 1754 | } |
| 1755 | if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) { |
| 1756 | const label = hue >= 260 ? 'Purple/violet' : 'Cyan'; |
| 1757 | findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` }); |
| 1758 | } |
| 1759 | } |
| 1760 | } |
| 1761 | |
| 1762 | return findings; |
| 1763 | } |
| 1764 | |
| 1765 | const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']); |
| 1766 | |
| 1767 | // Resolve a CSS font-size value to pixels by walking up the parent chain. |
| 1768 | // Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the |
| 1769 | // specified value verbatim — so for the Node path we walk parents ourselves. |
| 1770 | function resolveFontSizePx(el, win) { |
| 1771 | const chain = []; // raw font-size strings, leaf → root |
| 1772 | let cur = el; |
| 1773 | while (cur && cur.nodeType === 1) { |
| 1774 | const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize; |
| 1775 | chain.push(fs || ''); |
| 1776 | cur = cur.parentElement; |
| 1777 | } |
| 1778 | // Walk root → leaf, resolving each value relative to its parent context. |
| 1779 | let px = 16; // root default |
| 1780 | for (let i = chain.length - 1; i >= 0; i--) { |
| 1781 | const v = chain[i]; |
| 1782 | if (!v || v === 'inherit') continue; |
| 1783 | const num = parseFloat(v); |
| 1784 | if (isNaN(num)) continue; |
| 1785 | if (v.endsWith('px')) px = num; |
| 1786 | else if (v.endsWith('rem')) px = num * 16; |
| 1787 | else if (v.endsWith('em')) px = num * px; |
| 1788 | else if (v.endsWith('%')) px = (num / 100) * px; |
| 1789 | else px = num; // unitless — already resolved |
| 1790 | } |
| 1791 | return px; |
| 1792 | } |
| 1793 | |
| 1794 | // Resolve a CSS length value (line-height, letter-spacing, etc.) given a |
| 1795 | // known font-size context. Returns null for "normal" / unparseable values. |
| 1796 | function resolveLengthPx(value, fontSizePx) { |
| 1797 | if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null; |
| 1798 | const num = parseFloat(value); |
| 1799 | if (isNaN(num)) return null; |
| 1800 | if (value.endsWith('px')) return num; |
| 1801 | if (value.endsWith('rem')) return num * 16; |
| 1802 | if (value.endsWith('em')) return num * fontSizePx; |
| 1803 | if (value.endsWith('%')) return (num / 100) * fontSizePx; |
| 1804 | // Unitless line-height = multiplier, return px equivalent |
| 1805 | return num * fontSizePx; |
| 1806 | } |
| 1807 | |
| 1808 | // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in |
| 1809 | // jsdom and the browser). Two checks (line-length, cramped-padding) gate on |
| 1810 | // element rect dimensions, which jsdom can't compute — pass `rect: null` from |
| 1811 | // the Node adapter to skip those. |
| 1812 | // |
| 1813 | // Both adapters resolve font-size, line-height and letter-spacing to pixels |
| 1814 | // before calling this so the pure function only deals with numbers. |
| 1815 | function checkQuality(opts) { |
| 1816 | const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts; |
| 1817 | const findings = []; |
| 1818 | // Skip browser extension injected elements |
| 1819 | const elId = el.id || ''; |
| 1820 | if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings; |
| 1821 | |
| 1822 | // --- Line length too long --- (browser-only: needs rect.width) |
| 1823 | if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) { |
| 1824 | const charsPerLine = rect.width / (fontSize * 0.5); |
| 1825 | if (charsPerLine > lineMax + 5) { |
| 1826 | findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` }); |
| 1827 | } |
| 1828 | } |
| 1829 | |
| 1830 | // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels) |
| 1831 | // Vertical and horizontal thresholds are independent because line-height |
| 1832 | // already provides built-in vertical breathing room (the line box is taller |
| 1833 | // than the cap height), but horizontal has no equivalent. Both scale with |
| 1834 | // font-size — bigger text demands proportionally more padding. |
| 1835 | // vertical: max(4px, fontSize × 0.3) |
| 1836 | // horizontal: max(8px, fontSize × 0.5) |
| 1837 | if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) { |
| 1838 | const borders = { |
| 1839 | top: parseFloat(style.borderTopWidth) || 0, |
| 1840 | right: parseFloat(style.borderRightWidth) || 0, |
| 1841 | bottom: parseFloat(style.borderBottomWidth) || 0, |
| 1842 | left: parseFloat(style.borderLeftWidth) || 0, |
| 1843 | }; |
| 1844 | const borderCount = Object.values(borders).filter(w => w > 0).length; |
| 1845 | const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)'; |
| 1846 | if (borderCount >= 2 || hasBg) { |
| 1847 | const vPads = [], hPads = []; |
| 1848 | if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0); |
| 1849 | if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0); |
| 1850 | if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0); |
| 1851 | if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0); |
| 1852 | |
| 1853 | const vMin = vPads.length ? Math.min(...vPads) : Infinity; |
| 1854 | const hMin = hPads.length ? Math.min(...hPads) : Infinity; |
| 1855 | const vThresh = Math.max(4, fontSize * 0.3); |
| 1856 | const hThresh = Math.max(8, fontSize * 0.5); |
| 1857 | |
| 1858 | // Emit at most one finding per element — pick whichever axis is worse. |
| 1859 | if (vMin < vThresh) { |
| 1860 | findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` }); |
| 1861 | } else if (hMin < hThresh) { |
| 1862 | findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` }); |
| 1863 | } |
| 1864 | } |
| 1865 | } |
| 1866 | |
| 1867 | // --- Flush against a visible boundary --- |
| 1868 | // Fires when a container has a visible boundary (border, outline, OR a |
| 1869 | // non-transparent background) AND near-zero padding on the bounded |
| 1870 | // side(s) AND text-bearing children land flush against the boundary. |
| 1871 | // |
| 1872 | // Distinct from cramped-padding: that rule needs the element itself to |
| 1873 | // have direct text (hasDirectText). This rule targets the OPPOSITE |
| 1874 | // shape — a container with NO direct text, only children — which is |
| 1875 | // exactly what cramped-padding misses (a section wrapping a label + |
| 1876 | // list lands a free pass). |
| 1877 | // |
| 1878 | // The classic shape: agent writes `padding: 28px 0 0` shorthand on a |
| 1879 | // section that also has a border, zeroing horizontal padding so the |
| 1880 | // text-bearing children touch the side borders. Background and |
| 1881 | // outline count too: a colored card with zero padding has the same |
| 1882 | // visual failure mode. |
| 1883 | { |
| 1884 | const FLUSH_SKIP_TAGS = new Set(['HTML', 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ARTICLE', 'ASIDE', 'BUTTON', 'A', 'LABEL', 'SUMMARY', 'CODE', 'PRE', 'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'FIGURE', 'TABLE', 'TBODY', 'THEAD', 'TR', 'TD', 'TH']); |
| 1885 | const upperTag = tag ? tag.toUpperCase() : ''; |
| 1886 | const elPosition = style.position || ''; |
| 1887 | if ( |
| 1888 | !FLUSH_SKIP_TAGS.has(upperTag) && |
| 1889 | !hasDirectText && |
| 1890 | !['fixed', 'absolute'].includes(elPosition) && |
| 1891 | el.children && el.children.length > 0 |
| 1892 | ) { |
| 1893 | const isTransparent = (c) => |
| 1894 | !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' || |
| 1895 | /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c); |
| 1896 | |
| 1897 | const borderW = { |
| 1898 | top: parseFloat(style.borderTopWidth) || 0, |
| 1899 | right: parseFloat(style.borderRightWidth) || 0, |
| 1900 | bottom: parseFloat(style.borderBottomWidth) || 0, |
| 1901 | left: parseFloat(style.borderLeftWidth) || 0, |
| 1902 | }; |
| 1903 | const borderVisible = { |
| 1904 | top: borderW.top > 0 && !isTransparent(style.borderTopColor), |
| 1905 | right: borderW.right > 0 && !isTransparent(style.borderRightColor), |
| 1906 | bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor), |
| 1907 | left: borderW.left > 0 && !isTransparent(style.borderLeftColor), |
| 1908 | }; |
| 1909 | // Outline detection. jsdom decomposes `border` shorthand into |
| 1910 | // border{Top,…}Width/Color but does NOT decompose `outline` — |
| 1911 | // the longhands come back empty when the value was set via the |
| 1912 | // shorthand. Fall back to parsing `style.outline` ourselves. |
| 1913 | let outlineW = parseFloat(style.outlineWidth) || 0; |
| 1914 | let outlineStyleVal = style.outlineStyle || ''; |
| 1915 | let outlineColorVal = style.outlineColor || ''; |
| 1916 | if (!outlineW && style.outline) { |
| 1917 | const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/); |
| 1918 | if (wMatch) outlineW = parseFloat(wMatch[1]) || 0; |
| 1919 | if (!outlineStyleVal) { |
| 1920 | outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : ''; |
| 1921 | } |
| 1922 | if (!outlineColorVal) { |
| 1923 | const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/); |
| 1924 | if (cMatch) outlineColorVal = cMatch[1]; |
| 1925 | } |
| 1926 | } |
| 1927 | const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none'; |
| 1928 | const bgVisible = !isTransparent(style.backgroundColor); |
| 1929 | |
| 1930 | const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible; |
| 1931 | if (anyVisible) { |
| 1932 | // Resolve padding to px (jsdom returns raw "1.5rem" etc., not the |
| 1933 | // computed px value; parseFloat would strip the unit and treat |
| 1934 | // 1.5rem as 1.5px, false-flagging legitimate insets). |
| 1935 | const pad = { |
| 1936 | top: resolveLengthPx(style.paddingTop, fontSize) ?? 0, |
| 1937 | right: resolveLengthPx(style.paddingRight, fontSize) ?? 0, |
| 1938 | bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0, |
| 1939 | left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0, |
| 1940 | }; |
| 1941 | const PAD_THRESHOLD = 2; |
| 1942 | // Children-insulate-this-side: a side is insulated if ANY direct |
| 1943 | // child has its own padding ≥ 4px on that side. Rationale: in |
| 1944 | // typical flow, only the first/last (or leftmost/rightmost) |
| 1945 | // children actually sit at the parent's edges. If even one of |
| 1946 | // them has its own padding, the visual flush is broken on that |
| 1947 | // side. Classic example: a column-flow card frame where the |
| 1948 | // top child (header) has padding-top:12 and the bottom child |
| 1949 | // (footer) has padding-bottom:8 — the parent's padding:0 doesn't |
| 1950 | // matter; nothing is actually flush. The `any-child-insulates` |
| 1951 | // heuristic accepts some false negatives (a card with one heavily |
| 1952 | // padded middle child won't flag) for far fewer false positives. |
| 1953 | const CHILD_INSULATE_THRESHOLD = 4; |
| 1954 | const childrenInsulate = { top: false, right: false, bottom: false, left: false }; |
| 1955 | for (const child of el.children) { |
| 1956 | let childStyle = null; |
| 1957 | if (win && typeof win.getComputedStyle === 'function') { |
| 1958 | try { childStyle = win.getComputedStyle(child); } catch {} |
| 1959 | } |
| 1960 | if (!childStyle && typeof getComputedStyle === 'function') { |
| 1961 | try { childStyle = getComputedStyle(child); } catch {} |
| 1962 | } |
| 1963 | if (!childStyle) continue; |
| 1964 | const childPad = { |
| 1965 | top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0, |
| 1966 | right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0, |
| 1967 | bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0, |
| 1968 | left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0, |
| 1969 | }; |
| 1970 | for (const s of ['top', 'right', 'bottom', 'left']) { |
| 1971 | if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true; |
| 1972 | } |
| 1973 | } |
| 1974 | |
| 1975 | const flushSides = []; |
| 1976 | for (const side of ['top', 'right', 'bottom', 'left']) { |
| 1977 | const sideBounded = borderVisible[side] || outlineVisible || bgVisible; |
| 1978 | if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) { |
| 1979 | flushSides.push(side); |
| 1980 | } |
| 1981 | } |
| 1982 | |
| 1983 | if (flushSides.length > 0) { |
| 1984 | // Confirm at least one direct child has substantial text content |
| 1985 | // (> 4 chars). Without this, the flush is harmless: e.g. an |
| 1986 | // image-only card. |
| 1987 | let hasTextChild = false; |
| 1988 | for (const child of el.children) { |
| 1989 | const childText = (child.textContent || '').trim(); |
| 1990 | if (childText.length > 4) { hasTextChild = true; break; } |
| 1991 | } |
| 1992 | if (hasTextChild) { |
| 1993 | const cls = (typeof el.className === 'string' && el.className.trim()) |
| 1994 | ? el.className.trim().split(/\s+/)[0] |
| 1995 | : ''; |
| 1996 | const boundaryParts = []; |
| 1997 | const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]); |
| 1998 | if (borderSidesVisible.length === 4) boundaryParts.push('border'); |
| 1999 | else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`); |
| 2000 | if (outlineVisible) boundaryParts.push('outline'); |
| 2001 | if (bgVisible) boundaryParts.push('bg'); |
| 2002 | const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/'); |
| 2003 | const ident = cls |
| 2004 | ? `<${tag.toLowerCase()}> "${cls}"` |
| 2005 | : `<${tag.toLowerCase()}>`; |
| 2006 | findings.push({ |
| 2007 | id: 'cramped-padding', |
| 2008 | snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`, |
| 2009 | }); |
| 2010 | } |
| 2011 | } |
| 2012 | } |
| 2013 | } |
| 2014 | } |
| 2015 | |
| 2016 | // --- Body text touching viewport edge --- (browser-only: needs rect) |
| 2017 | // Catches the failure mode where the agent ships body paragraphs |
| 2018 | // with NO container providing horizontal padding — text bleeds |
| 2019 | // directly to the viewport edge. Different from cramped-padding, |
| 2020 | // which requires a colored/bordered container. Here the failure |
| 2021 | // is the absence of the container entirely. |
| 2022 | // |
| 2023 | // Gate aggressively to avoid false positives: |
| 2024 | // - <p> or <li> only (body content; not headings, not nav, not |
| 2025 | // wrappers) |
| 2026 | // - text > 40 chars (paragraph-like, not a label) |
| 2027 | // - rect.width > 50% of viewport (real body, not a pull-quote) |
| 2028 | // - rect.left < 16 OR rect.right > viewport - 16 (actually |
| 2029 | // touching the edge) |
| 2030 | // - not inside <nav> or <header> (those legitimately bleed) |
| 2031 | // - element itself has no background-color (intentional full-bleed |
| 2032 | // sections set a bg-color and provide their own internal padding) |
| 2033 | if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) { |
| 2034 | const inNavHeader = el.closest && (el.closest('nav') || el.closest('header')); |
| 2035 | const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'; |
| 2036 | const isPositioned = ['fixed', 'absolute'].includes(style.position || ''); |
| 2037 | const widthRatio = rect.width / viewportWidth; |
| 2038 | const leftClose = rect.left < 16; |
| 2039 | const rightClose = rect.right > viewportWidth - 16; |
| 2040 | if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) { |
| 2041 | const which = leftClose && rightClose |
| 2042 | ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px` |
| 2043 | : leftClose |
| 2044 | ? `left ${Math.round(rect.left)}px` |
| 2045 | : `right ${Math.round(viewportWidth - rect.right)}px`; |
| 2046 | findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` }); |
| 2047 | } |
| 2048 | } |
| 2049 | |
| 2050 | // --- Tight line height --- |
| 2051 | if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) { |
| 2052 | if (lineHeightPx != null && fontSize > 0) { |
| 2053 | const ratio = lineHeightPx / fontSize; |
| 2054 | if (ratio > 0 && ratio < 1.3) { |
| 2055 | findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` }); |
| 2056 | } |
| 2057 | } |
| 2058 | } |
| 2059 | |
| 2060 | // --- Justified text (without hyphens) --- |
| 2061 | if (hasDirectText && style.textAlign === 'justify') { |
| 2062 | const hyphens = style.hyphens || style.webkitHyphens || ''; |
| 2063 | if (hyphens !== 'auto') { |
| 2064 | findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' }); |
| 2065 | } |
| 2066 | } |
| 2067 | |
| 2068 | // --- Tiny body text --- |
| 2069 | // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.) |
| 2070 | if (hasDirectText && textLen > 20 && fontSize < 12) { |
| 2071 | const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption']; |
| 2072 | const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]'); |
| 2073 | const isUppercase = style.textTransform === 'uppercase'; |
| 2074 | if (!skipTags.includes(tag) && !inUIContext && !isUppercase) { |
| 2075 | findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` }); |
| 2076 | } |
| 2077 | } |
| 2078 | |
| 2079 | // --- All-caps body text --- |
| 2080 | if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') { |
| 2081 | if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) { |
| 2082 | findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` }); |
| 2083 | } |
| 2084 | } |
| 2085 | |
| 2086 | // --- Wide letter spacing on body text --- |
| 2087 | if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') { |
| 2088 | if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) { |
| 2089 | const trackingEm = letterSpacingPx / fontSize; |
| 2090 | if (trackingEm > 0.05) { |
| 2091 | findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` }); |
| 2092 | } |
| 2093 | } |
| 2094 | } |
| 2095 | |
| 2096 | // --- Crushed letter spacing (mirror of wide-tracking) --- |
| 2097 | // Tracking pulled tighter than ~-0.05em crushes characters into each other. |
| 2098 | // Optical tightening that display type legitimately wants (around -0.02em) |
| 2099 | // stays well above this floor. |
| 2100 | if (hasDirectText && textLen > 20 && fontSize > 0) { |
| 2101 | if (letterSpacingPx != null && letterSpacingPx < 0) { |
| 2102 | const trackingEm = letterSpacingPx / fontSize; |
| 2103 | if (trackingEm <= -0.05) { |
| 2104 | const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40); |
| 2105 | findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` }); |
| 2106 | } |
| 2107 | } |
| 2108 | } |
| 2109 | |
| 2110 | return findings; |
| 2111 | } |
| 2112 | |
| 2113 | function checkElementQualityDOM(el) { |
| 2114 | const tag = el.tagName.toLowerCase(); |
| 2115 | const style = getComputedStyle(el); |
| 2116 | const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); |
| 2117 | const textLen = el.textContent?.trim().length || 0; |
| 2118 | // Browser getComputedStyle resolves everything to px — direct parseFloat |
| 2119 | // works. |
| 2120 | const fontSize = parseFloat(style.fontSize) || 16; |
| 2121 | const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); |
| 2122 | const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); |
| 2123 | const rect = el.getBoundingClientRect(); |
| 2124 | const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80; |
| 2125 | const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0; |
| 2126 | return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null }); |
| 2127 | } |
| 2128 | |
| 2129 | // Pure page-level skipped-heading walk. Takes a Document so it works in both |
| 2130 | // the browser and jsdom. |
| 2131 | function checkPageQualityFromDoc(doc) { |
| 2132 | const findings = []; |
| 2133 | const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6'); |
| 2134 | let prevLevel = 0; |
| 2135 | let prevText = ''; |
| 2136 | for (const h of headings) { |
| 2137 | const level = parseInt(h.tagName[1]); |
| 2138 | const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60); |
| 2139 | if (prevLevel > 0 && level > prevLevel + 1) { |
| 2140 | findings.push({ |
| 2141 | id: 'skipped-heading', |
| 2142 | snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`, |
| 2143 | }); |
| 2144 | } |
| 2145 | prevLevel = level; |
| 2146 | prevText = text; |
| 2147 | } |
| 2148 | return findings; |
| 2149 | } |
| 2150 | |
| 2151 | // Browser adapter (returns the legacy { type, detail } shape used by the overlay loop) |
| 2152 | function checkPageQualityDOM() { |
| 2153 | return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet })); |
| 2154 | } |
| 2155 | |
| 2156 | // Node adapters — take pre-extracted jsdom computed style |
| 2157 | |
| 2158 | // jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every |
| 2159 | // CSS length the rule needs ourselves (walking the parent chain for |
| 2160 | // font-size inheritance), and pass `rect: null` to skip the two rules that |
| 2161 | // genuinely need element rects (line-length, cramped-padding). |
| 2162 | function checkElementQuality(el, style, tag, window) { |
| 2163 | const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10); |
| 2164 | const textLen = el.textContent?.trim().length || 0; |
| 2165 | const fontSize = resolveFontSizePx(el, window); |
| 2166 | const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize); |
| 2167 | const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize); |
| 2168 | return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window }); |
| 2169 | } |
| 2170 | |
| 2171 | function checkElementBorders(tag, style, overrides, resolvedRadius) { |
| 2172 | const sides = ['Top', 'Right', 'Bottom', 'Left']; |
| 2173 | const widths = {}, colors = {}; |
| 2174 | for (const s of sides) { |
| 2175 | widths[s] = parseFloat(style[`border${s}Width`]) || 0; |
| 2176 | colors[s] = style[`border${s}Color`] || ''; |
| 2177 | // jsdom silently drops any border shorthand containing var(), leaving |
| 2178 | // both width and color empty on the computed style. When the detectHtml |
| 2179 | // pre-pass pulled a resolved value off the rule, use it to fill in the |
| 2180 | // missing side so the side-tab check can run. Real browsers resolve |
| 2181 | // var() natively, so this fallback is a no-op in the browser path. |
| 2182 | if (widths[s] === 0 && overrides && overrides[s]) { |
| 2183 | widths[s] = overrides[s].width; |
| 2184 | colors[s] = overrides[s].color; |
| 2185 | } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) { |
| 2186 | // Longhand case: jsdom kept the width but left the color as the |
| 2187 | // literal `var(...)` string. Substitute the resolved color. |
| 2188 | colors[s] = overrides[s].color; |
| 2189 | } |
| 2190 | } |
| 2191 | // resolvedRadius lets the caller pre-resolve the radius via |
| 2192 | // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken |
| 2193 | // shorthand serialization. Falls back to the computed value for tests |
| 2194 | // and browser callers that don't pre-resolve. |
| 2195 | const radius = resolvedRadius != null |
| 2196 | ? resolvedRadius |
| 2197 | : (parseFloat(style.borderRadius) || 0); |
| 2198 | return checkBorders(tag, widths, colors, radius); |
| 2199 | } |
| 2200 | |
| 2201 | function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) { |
| 2202 | const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); |
| 2203 | const hasDirectText = directText.trim().length > 0; |
| 2204 | |
| 2205 | const effectiveBg = resolveBackground(el, window, customPropMap); |
| 2206 | // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain |
| 2207 | // parseRgb misses Tailwind-tokenized text colors. Resolve through the |
| 2208 | // customPropMap first; fall back to parseRgb for vanilla rgb() pages. |
| 2209 | let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null; |
| 2210 | if (!textColor) textColor = parseRgb(style.color); |
| 2211 | |
| 2212 | // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color: |
| 2213 | // blue }` at high specificity. The page's `a { color: inherit }` rule |
| 2214 | // (Tailwind v4 preflight) loses to jsdom even though it WINS in real |
| 2215 | // browsers (Chrome's UA wraps :link in :where() — zero specificity). |
| 2216 | // When the page declares the inherit rule AND we see jsdom's default |
| 2217 | // link blue on an anchor, walk to the nearest non-anchor ancestor and |
| 2218 | // use its color instead. |
| 2219 | if ( |
| 2220 | hasAnchorInheritRule && |
| 2221 | textColor && |
| 2222 | textColor.r === 0 && textColor.g === 0 && textColor.b === 238 && |
| 2223 | (tag === 'a' || el.closest?.('a')) |
| 2224 | ) { |
| 2225 | let cur = el.parentElement; |
| 2226 | while (cur && cur.tagName !== 'HTML') { |
| 2227 | if (cur.tagName !== 'A') { |
| 2228 | const ps = window.getComputedStyle(cur); |
| 2229 | const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color); |
| 2230 | if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) { |
| 2231 | textColor = inh; |
| 2232 | break; |
| 2233 | } |
| 2234 | } |
| 2235 | cur = cur.parentElement; |
| 2236 | } |
| 2237 | } |
| 2238 | |
| 2239 | return checkColors({ |
| 2240 | tag, |
| 2241 | textColor, |
| 2242 | bgColor: readOwnBackgroundColor(el, style), |
| 2243 | effectiveBg, |
| 2244 | effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window), |
| 2245 | fontSize: parseFloat(style.fontSize) || 16, |
| 2246 | fontWeight: parseInt(style.fontWeight) || 400, |
| 2247 | hasDirectText, |
| 2248 | isEmojiOnly: isEmojiOnlyText(directText), |
| 2249 | bgClip: style.webkitBackgroundClip || style.backgroundClip || '', |
| 2250 | bgImage: style.backgroundImage || '', |
| 2251 | classList: el.getAttribute?.('class') || el.className || '', |
| 2252 | }); |
| 2253 | } |
| 2254 | |
| 2255 | function checkElementIconTile(el, tag, window) { |
| 2256 | if (!HEADING_TAGS.has(tag)) return []; |
| 2257 | const sibling = el.previousElementSibling; |
| 2258 | if (!sibling) return []; |
| 2259 | |
| 2260 | const sibStyle = window.getComputedStyle(sibling); |
| 2261 | // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead. |
| 2262 | const sibWidth = parseFloat(sibStyle.width) || 0; |
| 2263 | const sibHeight = parseFloat(sibStyle.height) || 0; |
| 2264 | |
| 2265 | const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]'); |
| 2266 | let iconWidth = 0; |
| 2267 | if (iconChild) { |
| 2268 | const iconStyle = window.getComputedStyle(iconChild); |
| 2269 | iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0; |
| 2270 | } |
| 2271 | // Or: tile contains an emoji/symbol character directly as its only content |
| 2272 | const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(''); |
| 2273 | const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText); |
| 2274 | |
| 2275 | return checkIconTile({ |
| 2276 | headingTag: tag, |
| 2277 | headingText: el.textContent || '', |
| 2278 | headingTop: 0, // jsdom: no layout, skip vertical-stacking gate |
| 2279 | siblingTag: sibling.tagName.toLowerCase(), |
| 2280 | siblingWidth: sibWidth, |
| 2281 | siblingHeight: sibHeight, |
| 2282 | siblingBottom: 0, |
| 2283 | siblingBgColor: parseRgb(sibStyle.backgroundColor), |
| 2284 | siblingBgImage: sibStyle.backgroundImage || '', |
| 2285 | siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0, |
| 2286 | siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window), |
| 2287 | hasIconChild: !!iconChild || hasInlineEmojiIcon, |
| 2288 | iconChildWidth: iconWidth, |
| 2289 | }); |
| 2290 | } |
| 2291 | |
| 2292 | function checkElementItalicSerif(el, style, tag) { |
| 2293 | if (tag !== 'h1' && tag !== 'h2') return []; |
| 2294 | return checkItalicSerif({ |
| 2295 | tag, |
| 2296 | fontStyle: style.fontStyle || '', |
| 2297 | fontFamily: style.fontFamily || '', |
| 2298 | fontSize: parseFloat(style.fontSize) || 0, |
| 2299 | headingText: el.textContent || '', |
| 2300 | }); |
| 2301 | } |
| 2302 | |
| 2303 | function checkElementHeroEyebrow(el, style, tag, window, customPropMap) { |
| 2304 | if (tag !== 'h1') return []; |
| 2305 | const sibling = el.previousElementSibling; |
| 2306 | if (!sibling) return []; |
| 2307 | const sibStyle = window.getComputedStyle(sibling); |
| 2308 | // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold) |
| 2309 | // etc.) before parsing. jsdom returns these verbatim from getComputedStyle; |
| 2310 | // without resolution every style-based gate fails silently on Tailwind v4 builds. |
| 2311 | const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize; |
| 2312 | const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight; |
| 2313 | const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing; |
| 2314 | const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color; |
| 2315 | const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize; |
| 2316 | const siblingFontSize = parseFloat(fontSizeRaw) || 0; |
| 2317 | // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the |
| 2318 | // gate falls through cleanly. jsdom returns letter-spacing verbatim |
| 2319 | // (e.g. '0.15em'), unlike real browsers, so this conversion is required. |
| 2320 | return checkHeroEyebrow({ |
| 2321 | headingTag: tag, |
| 2322 | headingText: el.textContent || '', |
| 2323 | headingFontSize: parseFloat(headingFontSizeRaw) || 0, |
| 2324 | siblingTag: sibling.tagName.toLowerCase(), |
| 2325 | siblingText: sibling.textContent || '', |
| 2326 | siblingTextTransform: sibStyle.textTransform || '', |
| 2327 | siblingFontSize, |
| 2328 | siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0, |
| 2329 | siblingFontWeight: fontWeightRaw || '', |
| 2330 | siblingColor: colorRaw || '', |
| 2331 | }); |
| 2332 | } |
| 2333 | |
| 2334 | function checkRepeatedSectionKickersFromDoc(doc, win) { |
| 2335 | const candidates = collectRepeatedSectionKickerCandidates( |
| 2336 | doc, |
| 2337 | (el) => win.getComputedStyle(el), |
| 2338 | (value, fontSize) => resolveLengthPx(value, fontSize) || 0, |
| 2339 | ); |
| 2340 | return checkRepeatedSectionKickers({ candidates }); |
| 2341 | } |
| 2342 | |
| 2343 | function checkElementMotion(tag, style) { |
| 2344 | return checkMotion({ |
| 2345 | tag, |
| 2346 | transitionProperty: style.transitionProperty || '', |
| 2347 | animationName: style.animationName || '', |
| 2348 | timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '), |
| 2349 | classList: '', |
| 2350 | }); |
| 2351 | } |
| 2352 | |
| 2353 | function checkElementGlow(tag, style, effectiveBg) { |
| 2354 | if (!style.boxShadow || style.boxShadow === 'none') return []; |
| 2355 | return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg }); |
| 2356 | } |
| 2357 | |
| 2358 | // ─── Section 6: Page-Level Checks ─────────────────────────────────────────── |
| 2359 | |
| 2360 | // Browser page-level checks — use document/getComputedStyle globals |
| 2361 | |
| 2362 | function checkTypography() { |
| 2363 | const findings = []; |
| 2364 | |
| 2365 | // Walk actual text-bearing elements and tally font usage by *computed style*. |
| 2366 | // This is much more accurate than scanning CSS rules — it ignores rules that |
| 2367 | // exist in the stylesheet but apply to nothing (e.g. demo classes showing |
| 2368 | // anti-patterns), and counts what the user actually sees. |
| 2369 | const fontUsage = new Map(); // primary font name → count of elements |
| 2370 | let totalTextElements = 0; |
| 2371 | for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) { |
| 2372 | // Skip impeccable's own elements |
| 2373 | if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 2374 | // Only count elements that actually have visible direct text |
| 2375 | const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); |
| 2376 | if (!hasText) continue; |
| 2377 | const style = getComputedStyle(el); |
| 2378 | const ff = style.fontFamily; |
| 2379 | if (!ff) continue; |
| 2380 | const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); |
| 2381 | const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); |
| 2382 | if (!primary) continue; |
| 2383 | fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1); |
| 2384 | totalTextElements++; |
| 2385 | } |
| 2386 | |
| 2387 | if (totalTextElements >= 20) { |
| 2388 | // A font is "primary" if it's used by at least 15% of text elements |
| 2389 | const PRIMARY_THRESHOLD = 0.15; |
| 2390 | for (const [font, count] of fontUsage) { |
| 2391 | const share = count / totalTextElements; |
| 2392 | if (share < PRIMARY_THRESHOLD) continue; |
| 2393 | if (!OVERUSED_FONTS.has(font)) continue; |
| 2394 | if (isBrandFontOnOwnDomain(font)) continue; |
| 2395 | findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); |
| 2396 | } |
| 2397 | |
| 2398 | // Single-font check: only one distinct primary font across all text |
| 2399 | if (fontUsage.size === 1) { |
| 2400 | const only = [...fontUsage.keys()][0]; |
| 2401 | findings.push({ type: 'single-font', detail: `only font used is ${only}` }); |
| 2402 | } |
| 2403 | } |
| 2404 | |
| 2405 | const sizes = new Set(); |
| 2406 | for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { |
| 2407 | const fs = parseFloat(getComputedStyle(el).fontSize); |
| 2408 | if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); |
| 2409 | } |
| 2410 | if (sizes.size >= 3) { |
| 2411 | const sorted = [...sizes].sort((a, b) => a - b); |
| 2412 | const ratio = sorted[sorted.length - 1] / sorted[0]; |
| 2413 | if (ratio < 2.0) { |
| 2414 | findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); |
| 2415 | } |
| 2416 | } |
| 2417 | |
| 2418 | return findings; |
| 2419 | } |
| 2420 | |
| 2421 | function isCardLikeDOM(el) { |
| 2422 | const tag = el.tagName.toLowerCase(); |
| 2423 | if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false; |
| 2424 | const style = getComputedStyle(el); |
| 2425 | const cls = el.getAttribute('class') || ''; |
| 2426 | const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls); |
| 2427 | const hasBorder = /\bborder\b/.test(cls); |
| 2428 | const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls); |
| 2429 | const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls); |
| 2430 | return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); |
| 2431 | } |
| 2432 | |
| 2433 | function checkLayout() { |
| 2434 | const findings = []; |
| 2435 | const flaggedEls = new Set(); |
| 2436 | |
| 2437 | for (const el of document.querySelectorAll('*')) { |
| 2438 | if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue; |
| 2439 | const cls = el.getAttribute('class') || ''; |
| 2440 | const style = getComputedStyle(el); |
| 2441 | if (style.position === 'absolute' || style.position === 'fixed') continue; |
| 2442 | if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; |
| 2443 | if ((el.textContent?.trim().length || 0) < 10) continue; |
| 2444 | const rect = el.getBoundingClientRect(); |
| 2445 | if (rect.width < 50 || rect.height < 30) continue; |
| 2446 | |
| 2447 | let parent = el.parentElement; |
| 2448 | while (parent) { |
| 2449 | if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; } |
| 2450 | parent = parent.parentElement; |
| 2451 | } |
| 2452 | } |
| 2453 | |
| 2454 | for (const el of flaggedEls) { |
| 2455 | let isAncestor = false; |
| 2456 | for (const other of flaggedEls) { |
| 2457 | if (other !== el && el.contains(other)) { isAncestor = true; break; } |
| 2458 | } |
| 2459 | if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el }); |
| 2460 | } |
| 2461 | |
| 2462 | return findings; |
| 2463 | } |
| 2464 | |
| 2465 | // Node page-level checks — take document/window as parameters |
| 2466 | |
| 2467 | function checkPageTypography(doc, win) { |
| 2468 | const findings = []; |
| 2469 | |
| 2470 | const fonts = new Set(); |
| 2471 | const overusedFound = new Set(); |
| 2472 | |
| 2473 | for (const sheet of doc.styleSheets) { |
| 2474 | let rules; |
| 2475 | try { rules = sheet.cssRules || sheet.rules; } catch { continue; } |
| 2476 | if (!rules) continue; |
| 2477 | for (const rule of rules) { |
| 2478 | if (rule.type !== 1) continue; |
| 2479 | const ff = rule.style?.fontFamily; |
| 2480 | if (!ff) continue; |
| 2481 | const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); |
| 2482 | const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); |
| 2483 | if (primary) { |
| 2484 | fonts.add(primary); |
| 2485 | if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); |
| 2486 | } |
| 2487 | } |
| 2488 | } |
| 2489 | |
| 2490 | // Check Google Fonts links in HTML |
| 2491 | const html = doc.documentElement?.outerHTML || ''; |
| 2492 | const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; |
| 2493 | let m; |
| 2494 | while ((m = gfRe.exec(html)) !== null) { |
| 2495 | const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); |
| 2496 | for (const f of families) { |
| 2497 | fonts.add(f); |
| 2498 | if (OVERUSED_FONTS.has(f)) overusedFound.add(f); |
| 2499 | } |
| 2500 | } |
| 2501 | |
| 2502 | // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM) |
| 2503 | const ffRe = /font-family\s*:\s*([^;}]+)/gi; |
| 2504 | let fm; |
| 2505 | while ((fm = ffRe.exec(html)) !== null) { |
| 2506 | for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { |
| 2507 | if (f && !GENERIC_FONTS.has(f)) { |
| 2508 | fonts.add(f); |
| 2509 | if (OVERUSED_FONTS.has(f)) overusedFound.add(f); |
| 2510 | } |
| 2511 | } |
| 2512 | } |
| 2513 | |
| 2514 | for (const font of overusedFound) { |
| 2515 | findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); |
| 2516 | } |
| 2517 | |
| 2518 | // Single font |
| 2519 | if (fonts.size === 1) { |
| 2520 | const els = doc.querySelectorAll('*'); |
| 2521 | if (els.length >= 20) { |
| 2522 | findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` }); |
| 2523 | } |
| 2524 | } |
| 2525 | |
| 2526 | // Flat type hierarchy |
| 2527 | const sizes = new Set(); |
| 2528 | const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); |
| 2529 | for (const el of textEls) { |
| 2530 | const fontSize = parseFloat(win.getComputedStyle(el).fontSize); |
| 2531 | // Filter out sub-8px values (jsdom doesn't resolve relative units properly) |
| 2532 | if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10); |
| 2533 | } |
| 2534 | if (sizes.size >= 3) { |
| 2535 | const sorted = [...sizes].sort((a, b) => a - b); |
| 2536 | const ratio = sorted[sorted.length - 1] / sorted[0]; |
| 2537 | if (ratio < 2.0) { |
| 2538 | findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); |
| 2539 | } |
| 2540 | } |
| 2541 | |
| 2542 | return findings; |
| 2543 | } |
| 2544 | |
| 2545 | function isCardLike(el, win) { |
| 2546 | const tag = el.tagName.toLowerCase(); |
| 2547 | if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false; |
| 2548 | |
| 2549 | const style = win.getComputedStyle(el); |
| 2550 | const rawStyle = el.getAttribute?.('style') || ''; |
| 2551 | const cls = el.getAttribute?.('class') || ''; |
| 2552 | |
| 2553 | const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || |
| 2554 | /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle); |
| 2555 | const hasBorder = /\bborder\b/.test(cls); |
| 2556 | const widthPx = parseFloat(style.width) || 0; |
| 2557 | const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 || |
| 2558 | /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle); |
| 2559 | const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) || |
| 2560 | /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle); |
| 2561 | |
| 2562 | return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg); |
| 2563 | } |
| 2564 | |
| 2565 | function checkPageLayout(doc, win) { |
| 2566 | const findings = []; |
| 2567 | |
| 2568 | // Nested cards |
| 2569 | const allEls = doc.querySelectorAll('*'); |
| 2570 | const flaggedEls = new Set(); |
| 2571 | for (const el of allEls) { |
| 2572 | if (!isCardLike(el, win)) continue; |
| 2573 | if (flaggedEls.has(el)) continue; |
| 2574 | |
| 2575 | const tag = el.tagName.toLowerCase(); |
| 2576 | const cls = el.getAttribute?.('class') || ''; |
| 2577 | const rawStyle = el.getAttribute?.('style') || ''; |
| 2578 | |
| 2579 | if (['pre', 'code'].includes(tag)) continue; |
| 2580 | if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue; |
| 2581 | if ((el.textContent?.trim().length || 0) < 10) continue; |
| 2582 | if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue; |
| 2583 | |
| 2584 | // Walk up to find card-like ancestor |
| 2585 | let parent = el.parentElement; |
| 2586 | while (parent) { |
| 2587 | if (isCardLike(parent, win)) { |
| 2588 | flaggedEls.add(el); |
| 2589 | break; |
| 2590 | } |
| 2591 | parent = parent.parentElement; |
| 2592 | } |
| 2593 | } |
| 2594 | |
| 2595 | // Only report innermost nested cards |
| 2596 | for (const el of flaggedEls) { |
| 2597 | let isAncestorOfFlagged = false; |
| 2598 | for (const other of flaggedEls) { |
| 2599 | if (other !== el && el.contains(other)) { |
| 2600 | isAncestorOfFlagged = true; |
| 2601 | break; |
| 2602 | } |
| 2603 | } |
| 2604 | if (!isAncestorOfFlagged) { |
| 2605 | findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` }); |
| 2606 | } |
| 2607 | } |
| 2608 | |
| 2609 | return findings; |
| 2610 | } |
| 2611 | |
| 2612 | // ─── Cream / beige palette (the default "tasteful" AI surface) ──────────────── |
| 2613 | // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a |
| 2614 | // small warm tint (not white, not a strong color). The current reflex surface. |
| 2615 | function isCreamColor(rgb) { |
| 2616 | if (!rgb) return false; |
| 2617 | const { r, g, b } = rgb; |
| 2618 | if (Math.min(r, g, b) < 209) return false; // must be light |
| 2619 | if (!(r >= g && g >= b)) return false; // warm ordering |
| 2620 | const warmth = r - b; |
| 2621 | return warmth >= 6 && warmth <= 48; // tinted, not white, not strong |
| 2622 | } |
| 2623 | |
| 2624 | // Tailwind background utilities that render as a warm off-white surface. The |
| 2625 | // static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body> |
| 2626 | // resolves to nothing in computed style — catch it from the class list |
| 2627 | // instead. Candidate tokens map to their actual Tailwind hex and are still |
| 2628 | // filtered through isCreamColor, so neutral grays (stone) and over-saturated |
| 2629 | // shades drop out on their own. |
| 2630 | const TAILWIND_BG_HEX = { |
| 2631 | 'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7', |
| 2632 | 'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5', |
| 2633 | 'bg-yellow-50': '#fefce8', |
| 2634 | 'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4', |
| 2635 | }; |
| 2636 | |
| 2637 | function creamFromClassList(cls) { |
| 2638 | if (!cls) return null; |
| 2639 | // Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces). |
| 2640 | const arb = cls.match(/\bbg-\[([^\]]+)\]/); |
| 2641 | if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`; |
| 2642 | // Named warm-light utilities. |
| 2643 | for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) { |
| 2644 | if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok; |
| 2645 | } |
| 2646 | return null; |
| 2647 | } |
| 2648 | |
| 2649 | function checkCreamPalette(doc, win) { |
| 2650 | const findings = []; |
| 2651 | const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null); |
| 2652 | if (!body) return findings; |
| 2653 | const html = doc.documentElement; |
| 2654 | const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el)); |
| 2655 | |
| 2656 | // 1. Computed background — covers inline / <style> / linked CSS, and Tailwind |
| 2657 | // once it's actually rendered (browser path). |
| 2658 | let bg = readOwnBackgroundColor(body, getCS(body)); |
| 2659 | if (!bg || bg.a === 0) { |
| 2660 | if (html) bg = readOwnBackgroundColor(html, getCS(html)); |
| 2661 | } |
| 2662 | if (isCreamColor(bg)) { |
| 2663 | findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` }); |
| 2664 | return findings; |
| 2665 | } |
| 2666 | |
| 2667 | // 2. Tailwind class fallback — for the static path, where utility classes |
| 2668 | // never resolve to computed CSS. |
| 2669 | for (const el of [body, html]) { |
| 2670 | const tok = creamFromClassList(el && el.getAttribute ? el.getAttribute('class') : ''); |
| 2671 | if (tok) { |
| 2672 | findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` }); |
| 2673 | break; |
| 2674 | } |
| 2675 | } |
| 2676 | return findings; |
| 2677 | } |
| 2678 | |
| 2679 | // ─── Oversized hero headline ──────────────────────────────────────────────── |
| 2680 | // Fires when a *long* headline is set at display size, so a full sentence ends |
| 2681 | // up dominating the viewport. A punchy one- or two-word headline at the same |
| 2682 | // size is a legitimate stylistic choice and must pass — length, not size |
| 2683 | // alone, is the tell. |
| 2684 | const OVERSIZED_H1_FONT_PX = 72; |
| 2685 | const OVERSIZED_H1_MIN_CHARS = 40; |
| 2686 | function checkOversizedH1({ tag, fontSize, headingText }) { |
| 2687 | if (tag !== 'h1') return []; |
| 2688 | const textLen = headingText.length; |
| 2689 | if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) { |
| 2690 | return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }]; |
| 2691 | } |
| 2692 | return []; |
| 2693 | } |
| 2694 | |
| 2695 | function checkElementOversizedH1(el, style, tag, window) { |
| 2696 | if (tag !== 'h1') return []; |
| 2697 | const fontSize = resolveFontSizePx(el, window); |
| 2698 | const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); |
| 2699 | return checkOversizedH1({ tag, fontSize, headingText }); |
| 2700 | } |
| 2701 | |
| 2702 | function checkElementOversizedH1DOM(el) { |
| 2703 | const tag = el.tagName.toLowerCase(); |
| 2704 | if (tag !== 'h1') return []; |
| 2705 | const style = getComputedStyle(el); |
| 2706 | const fontSize = parseFloat(style.fontSize) || 0; |
| 2707 | const headingText = (el.textContent || '').trim().replace(/\s+/g, ' '); |
| 2708 | return checkOversizedH1({ tag, fontSize, headingText }); |
| 2709 | } |
| 2710 | |
| 2711 | // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ──────────── |
| 2712 | function shadowMaxBlurPx(boxShadow) { |
| 2713 | if (!boxShadow || boxShadow === 'none') return 0; |
| 2714 | let maxBlur = 0; |
| 2715 | // Split into layers on commas not inside parentheses (rgba(...) etc.). |
| 2716 | for (const layer of boxShadow.split(/,(?![^()]*\))/)) { |
| 2717 | // Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the |
| 2718 | // ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps |
| 2719 | // unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") — |
| 2720 | // both reduce to the same numbers here. |
| 2721 | const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' '); |
| 2722 | const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0])); |
| 2723 | if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]); |
| 2724 | } |
| 2725 | return maxBlur; |
| 2726 | } |
| 2727 | |
| 2728 | function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) { |
| 2729 | const maxBorder = Math.max(0, ...borderWidths); |
| 2730 | const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5; |
| 2731 | const blur = shadowMaxBlurPx(boxShadow); |
| 2732 | if (hasThinBorder && blur >= 16) { |
| 2733 | return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }]; |
| 2734 | } |
| 2735 | return []; |
| 2736 | } |
| 2737 | |
| 2738 | function borderWidthsFromStyle(style) { |
| 2739 | return [ |
| 2740 | parseFloat(style.borderTopWidth) || 0, |
| 2741 | parseFloat(style.borderRightWidth) || 0, |
| 2742 | parseFloat(style.borderBottomWidth) || 0, |
| 2743 | parseFloat(style.borderLeftWidth) || 0, |
| 2744 | ]; |
| 2745 | } |
| 2746 | |
| 2747 | function checkElementGptBorderShadow(el, style) { |
| 2748 | return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); |
| 2749 | } |
| 2750 | |
| 2751 | function checkElementGptBorderShadowDOM(el) { |
| 2752 | const style = getComputedStyle(el); |
| 2753 | return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' }); |
| 2754 | } |
| 2755 | |
| 2756 | // ─── Clipped overflow container ─────────────────────────────────────────────── |
| 2757 | // A clipping container (overflow hidden/clip, not a scroll region) wrapping an |
| 2758 | // absolutely/fixed-positioned descendant clips popovers/menus that must escape. |
| 2759 | function classSelector(el) { |
| 2760 | const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || ''; |
| 2761 | const tokens = String(cls).trim().split(/\s+/).filter(Boolean); |
| 2762 | const tag = el.tagName ? el.tagName.toLowerCase() : 'el'; |
| 2763 | return tokens.length ? `${tag}.${tokens.join('.')}` : tag; |
| 2764 | } |
| 2765 | |
| 2766 | function checkClippedOverflow(el, style, getStyle) { |
| 2767 | const clips = (v) => v === 'hidden' || v === 'clip'; |
| 2768 | const scrolls = (v) => v === 'auto' || v === 'scroll'; |
| 2769 | const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || ''; |
| 2770 | const anyClip = clips(ox) || clips(oy) || clips(ov); |
| 2771 | const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov); |
| 2772 | if (!anyClip || anyScroll) return []; |
| 2773 | if (!el.querySelectorAll) return []; |
| 2774 | for (const child of el.querySelectorAll('*')) { |
| 2775 | const pos = (getStyle(child).position) || ''; |
| 2776 | if (pos === 'absolute' || pos === 'fixed') { |
| 2777 | return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }]; |
| 2778 | } |
| 2779 | } |
| 2780 | return []; |
| 2781 | } |
| 2782 | |
| 2783 | function checkElementClippedOverflow(el, style, tag, window) { |
| 2784 | return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n)); |
| 2785 | } |
| 2786 | |
| 2787 | function checkElementClippedOverflowDOM(el) { |
| 2788 | const style = getComputedStyle(el); |
| 2789 | return checkClippedOverflow(el, style, (n) => getComputedStyle(n)); |
| 2790 | } |
| 2791 | |
| 2792 | // ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ────────────── |
| 2793 | const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']); |
| 2794 | |
| 2795 | function checkElementTextOverflowDOM(el) { |
| 2796 | const tag = el.tagName.toLowerCase(); |
| 2797 | if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return []; |
| 2798 | // Only the element that actually owns overflowing text — not its ancestors, |
| 2799 | // which inherit a wider scrollWidth from the spilling descendant. |
| 2800 | const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0); |
| 2801 | if (!hasDirectText) return []; |
| 2802 | const style = getComputedStyle(el); |
| 2803 | const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || ''); |
| 2804 | if (isScrollRegion(style)) return []; |
| 2805 | // A scrollable ancestor means this overflow is intentional and scrollable. |
| 2806 | for (let p = el.parentElement; p; p = p.parentElement) { |
| 2807 | if (isScrollRegion(getComputedStyle(p))) return []; |
| 2808 | } |
| 2809 | const delta = el.scrollWidth - el.clientWidth; |
| 2810 | if (el.clientWidth > 0 && delta >= 16) { |
| 2811 | return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }]; |
| 2812 | } |
| 2813 | return []; |
| 2814 | } |
| 2815 | |
| 2816 | // --- cli/engine/browser/injected/index.mjs --- |
| 2817 | const IS_BROWSER = typeof window !== 'undefined'; |
| 2818 | |
| 2819 | // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── |
| 2820 | |
| 2821 | if (IS_BROWSER) { |
| 2822 | // Detect extension mode via the script tag's data attribute or the document element fallback. |
| 2823 | // currentScript is reliable for synchronously-executing scripts (which our IIFE is). |
| 2824 | const _myScript = document.currentScript; |
| 2825 | const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') |
| 2826 | || document.documentElement.dataset.impeccableExtension === 'true'; |
| 2827 | |
| 2828 | // Kinpaku gold — pinned to the site's brand token (see |
| 2829 | // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with |
| 2830 | // the picker's C.brand in skill/scripts/live-browser.js and the kit's |
| 2831 | // picker section in site/styles/kinpaku-kit.css. |
| 2832 | // |
| 2833 | // One color across both light and dark host pages. The outline is a |
| 2834 | // 2px gesture pointing at an element + a labeled tag — it's a marker, |
| 2835 | // not body text, so it doesn't need WCAG AA against the page. The |
| 2836 | // label text inside the gold tag is dark (LABEL_INK) which has ~16:1 |
| 2837 | // against the leaf gold, so reading the rule name is solid in both |
| 2838 | // modes. Hover deepens the gold (preserves chroma — never drops it, |
| 2839 | // dropping chroma washes the gold into a sand/olive tone). |
| 2840 | const BRAND_COLOR = 'oklch(84% 0.19 80.46)'; |
| 2841 | const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)'; |
| 2842 | const LABEL_INK = 'oklch(4% 0.004 95)'; |
| 2843 | const LABEL_BG = BRAND_COLOR; |
| 2844 | const OUTLINE_COLOR = BRAND_COLOR; |
| 2845 | |
| 2846 | // Inject hover styles via CSS (more reliable than JS event listeners) |
| 2847 | const styleEl = document.createElement('style'); |
| 2848 | styleEl.textContent = ` |
| 2849 | @keyframes impeccable-reveal { |
| 2850 | from { opacity: 0; } |
| 2851 | to { opacity: 1; } |
| 2852 | } |
| 2853 | .impeccable-overlay:not(.impeccable-banner) { |
| 2854 | pointer-events: none; |
| 2855 | outline: 2px solid ${OUTLINE_COLOR}; |
| 2856 | border-radius: 4px; |
| 2857 | transition: outline-color 0.15s ease; |
| 2858 | animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; |
| 2859 | animation-play-state: paused; |
| 2860 | border-top-left-radius: 0; |
| 2861 | } |
| 2862 | .impeccable-overlay.impeccable-visible { |
| 2863 | animation-play-state: running; |
| 2864 | } |
| 2865 | .impeccable-overlay.impeccable-hover { |
| 2866 | outline-color: ${BRAND_COLOR_HOVER}; |
| 2867 | z-index: 100001 !important; |
| 2868 | } |
| 2869 | .impeccable-overlay.impeccable-hover .impeccable-label { |
| 2870 | background: ${BRAND_COLOR_HOVER}; |
| 2871 | } |
| 2872 | .impeccable-overlay.impeccable-spotlight { |
| 2873 | z-index: 100002 !important; |
| 2874 | } |
| 2875 | .impeccable-overlay.impeccable-spotlight-dimmed { |
| 2876 | opacity: 0.15 !important; |
| 2877 | animation: none !important; |
| 2878 | filter: blur(3px); |
| 2879 | } |
| 2880 | .impeccable-spotlight-backdrop { |
| 2881 | position: fixed; |
| 2882 | top: 0; left: 0; right: 0; bottom: 0; |
| 2883 | backdrop-filter: blur(3px) brightness(0.6); |
| 2884 | -webkit-backdrop-filter: blur(3px) brightness(0.6); |
| 2885 | pointer-events: none; |
| 2886 | z-index: 99998; |
| 2887 | opacity: 0; |
| 2888 | outline: none !important; |
| 2889 | animation: none !important; |
| 2890 | } |
| 2891 | .impeccable-spotlight-backdrop.impeccable-visible { |
| 2892 | opacity: 1; |
| 2893 | } |
| 2894 | .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { |
| 2895 | display: none !important; |
| 2896 | } |
| 2897 | `; |
| 2898 | (document.head || document.documentElement).appendChild(styleEl); |
| 2899 | |
| 2900 | // Spotlight backdrop element (created lazily on first use) |
| 2901 | let spotlightBackdrop = null; |
| 2902 | let spotlightTarget = null; |
| 2903 | |
| 2904 | function getSpotlightBackdrop() { |
| 2905 | if (!spotlightBackdrop) { |
| 2906 | spotlightBackdrop = document.createElement('div'); |
| 2907 | spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; |
| 2908 | document.body.appendChild(spotlightBackdrop); |
| 2909 | } |
| 2910 | return spotlightBackdrop; |
| 2911 | } |
| 2912 | |
| 2913 | function updateSpotlightClipPath() { |
| 2914 | if (!spotlightBackdrop || !spotlightTarget) return; |
| 2915 | const r = spotlightTarget.getBoundingClientRect(); |
| 2916 | // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) |
| 2917 | const inset = 4; |
| 2918 | const radius = 6; // outline border-radius (4) + outline width (2) |
| 2919 | const x1 = r.left - inset; |
| 2920 | const y1 = r.top - inset; |
| 2921 | const x2 = r.right + inset; |
| 2922 | const y2 = r.bottom + inset; |
| 2923 | const vw = window.innerWidth; |
| 2924 | const vh = window.innerHeight; |
| 2925 | // Outer rect + rounded inner rect (evenodd creates a hole) |
| 2926 | 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`; |
| 2927 | spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; |
| 2928 | } |
| 2929 | |
| 2930 | function showSpotlight(target) { |
| 2931 | if (!target || !target.getBoundingClientRect) return; |
| 2932 | // Respect the spotlightBlur setting: if disabled, don't show the backdrop |
| 2933 | if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { |
| 2934 | spotlightTarget = target; |
| 2935 | return; |
| 2936 | } |
| 2937 | spotlightTarget = target; |
| 2938 | const bd = getSpotlightBackdrop(); |
| 2939 | updateSpotlightClipPath(); |
| 2940 | bd.classList.add('impeccable-visible'); |
| 2941 | } |
| 2942 | |
| 2943 | function hideSpotlight() { |
| 2944 | spotlightTarget = null; |
| 2945 | if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); |
| 2946 | } |
| 2947 | |
| 2948 | function isInViewport(el) { |
| 2949 | const r = el.getBoundingClientRect(); |
| 2950 | return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; |
| 2951 | } |
| 2952 | |
| 2953 | // Reposition spotlight on scroll/resize |
| 2954 | window.addEventListener('scroll', () => { |
| 2955 | if (spotlightTarget) updateSpotlightClipPath(); |
| 2956 | }, { passive: true }); |
| 2957 | window.addEventListener('resize', () => { |
| 2958 | if (spotlightTarget) updateSpotlightClipPath(); |
| 2959 | }); |
| 2960 | |
| 2961 | const overlays = []; |
| 2962 | const TYPE_LABELS = {}; |
| 2963 | const RULE_CATEGORY = {}; |
| 2964 | for (const ap of ANTIPATTERNS) { |
| 2965 | TYPE_LABELS[ap.id] = ap.name.toLowerCase(); |
| 2966 | RULE_CATEGORY[ap.id] = ap.category || 'quality'; |
| 2967 | } |
| 2968 | |
| 2969 | function isInFixedContext(el) { |
| 2970 | let p = el; |
| 2971 | while (p && p !== document.body) { |
| 2972 | if (getComputedStyle(p).position === 'fixed') return true; |
| 2973 | p = p.parentElement; |
| 2974 | } |
| 2975 | return false; |
| 2976 | } |
| 2977 | |
| 2978 | function positionOverlay(overlay) { |
| 2979 | const el = overlay._targetEl; |
| 2980 | if (!el) return; |
| 2981 | const rect = el.getBoundingClientRect(); |
| 2982 | if (overlay._isFixed) { |
| 2983 | // Viewport-relative coords for fixed targets |
| 2984 | overlay.style.top = `${rect.top - 2}px`; |
| 2985 | overlay.style.left = `${rect.left - 2}px`; |
| 2986 | } else { |
| 2987 | // Document-relative coords for normal targets |
| 2988 | overlay.style.top = `${rect.top + scrollY - 2}px`; |
| 2989 | overlay.style.left = `${rect.left + scrollX - 2}px`; |
| 2990 | } |
| 2991 | overlay.style.width = `${rect.width + 4}px`; |
| 2992 | overlay.style.height = `${rect.height + 4}px`; |
| 2993 | } |
| 2994 | |
| 2995 | function repositionOverlays() { |
| 2996 | for (const o of overlays) { |
| 2997 | if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; |
| 2998 | // Skip overlays whose target is currently hidden (display: none on the overlay) |
| 2999 | if (o.style.display === 'none') continue; |
| 3000 | positionOverlay(o); |
| 3001 | } |
| 3002 | } |
| 3003 | |
| 3004 | let resizeRAF; |
| 3005 | const onResize = () => { |
| 3006 | cancelAnimationFrame(resizeRAF); |
| 3007 | resizeRAF = requestAnimationFrame(repositionOverlays); |
| 3008 | }; |
| 3009 | window.addEventListener('resize', onResize); |
| 3010 | // Reposition on scroll too -- catches sticky/parallax shifts |
| 3011 | window.addEventListener('scroll', onResize, { passive: true }); |
| 3012 | // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) |
| 3013 | if (typeof ResizeObserver !== 'undefined') { |
| 3014 | const bodyResizeObserver = new ResizeObserver(onResize); |
| 3015 | bodyResizeObserver.observe(document.body); |
| 3016 | } |
| 3017 | |
| 3018 | // Track target element visibility via IntersectionObserver. |
| 3019 | // Uses a huge rootMargin so all *rendered* elements count as intersecting, |
| 3020 | // while display:none / closed <details> / hidden modals etc. do not. |
| 3021 | // This is event-driven -- no polling needed. |
| 3022 | let overlayIndex = 0; |
| 3023 | const visibilityObserver = new IntersectionObserver((entries) => { |
| 3024 | for (const entry of entries) { |
| 3025 | const overlay = entry.target._impeccableOverlay; |
| 3026 | if (!overlay) continue; |
| 3027 | if (entry.isIntersecting) { |
| 3028 | overlay.style.display = ''; |
| 3029 | positionOverlay(overlay); |
| 3030 | if (!overlay._revealed) { |
| 3031 | overlay._revealed = true; |
| 3032 | if (firstScanDone) { |
| 3033 | // Subsequent reveals (re-scans, scroll-into-view): instant, no animation |
| 3034 | overlay.style.animation = 'none'; |
| 3035 | } else { |
| 3036 | // Initial scan: staggered cascade reveal |
| 3037 | overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; |
| 3038 | } |
| 3039 | requestAnimationFrame(() => { |
| 3040 | overlay.classList.add('impeccable-visible'); |
| 3041 | if (overlay._checkLabel) overlay._checkLabel(); |
| 3042 | }); |
| 3043 | } |
| 3044 | } else { |
| 3045 | overlay.style.display = 'none'; |
| 3046 | } |
| 3047 | } |
| 3048 | }, { rootMargin: '99999px' }); |
| 3049 | |
| 3050 | function detachOverlay(overlay) { |
| 3051 | if (!overlay) return; |
| 3052 | if (typeof overlay._cleanup === 'function') { |
| 3053 | try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } |
| 3054 | } |
| 3055 | if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { |
| 3056 | visibilityObserver.unobserve(overlay._targetEl); |
| 3057 | delete overlay._targetEl._impeccableOverlay; |
| 3058 | } |
| 3059 | const idx = overlays.indexOf(overlay); |
| 3060 | if (idx >= 0) overlays.splice(idx, 1); |
| 3061 | overlay.remove(); |
| 3062 | } |
| 3063 | |
| 3064 | // Reposition overlays after CSS transitions end (e.g. reveal animations). |
| 3065 | // Listens at document level so it catches transitions on ancestor elements |
| 3066 | // (the transform may be on a parent, not the flagged element itself). |
| 3067 | document.addEventListener('transitionend', (e) => { |
| 3068 | if (e.propertyName !== 'transform') return; |
| 3069 | for (const o of overlays) { |
| 3070 | if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; |
| 3071 | if (e.target === o._targetEl || e.target.contains(o._targetEl)) { |
| 3072 | positionOverlay(o); |
| 3073 | } |
| 3074 | } |
| 3075 | }); |
| 3076 | |
| 3077 | const highlight = function(el, findings) { |
| 3078 | if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); |
| 3079 | const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); |
| 3080 | |
| 3081 | const fixed = isInFixedContext(el); |
| 3082 | const rect = el.getBoundingClientRect(); |
| 3083 | const outline = document.createElement('div'); |
| 3084 | outline.className = 'impeccable-overlay'; |
| 3085 | outline._targetEl = el; |
| 3086 | outline._isFixed = fixed; |
| 3087 | Object.assign(outline.style, { |
| 3088 | position: fixed ? 'fixed' : 'absolute', |
| 3089 | top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, |
| 3090 | left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, |
| 3091 | width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, |
| 3092 | zIndex: '99999', boxSizing: 'border-box', |
| 3093 | }); |
| 3094 | |
| 3095 | // Build per-finding label entries: ✦ prefix for slop |
| 3096 | const entries = findings.map(f => { |
| 3097 | const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; |
| 3098 | const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; |
| 3099 | return { name: prefix + name, detail: f.detail || f.snippet }; |
| 3100 | }); |
| 3101 | const allText = entries.map(e => e.name).join(', '); |
| 3102 | |
| 3103 | const label = document.createElement('div'); |
| 3104 | label.className = 'impeccable-label'; |
| 3105 | Object.assign(label.style, { |
| 3106 | position: 'absolute', bottom: '100%', left: '-2px', |
| 3107 | display: 'flex', alignItems: 'center', |
| 3108 | whiteSpace: 'nowrap', |
| 3109 | fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', |
| 3110 | color: LABEL_INK, lineHeight: '14px', |
| 3111 | background: LABEL_BG, |
| 3112 | fontFamily: 'system-ui, sans-serif', |
| 3113 | borderRadius: '4px 4px 0 0', |
| 3114 | }); |
| 3115 | |
| 3116 | const textSpan = document.createElement('span'); |
| 3117 | textSpan.style.padding = '3px 8px'; |
| 3118 | textSpan.textContent = allText; |
| 3119 | label.appendChild(textSpan); |
| 3120 | |
| 3121 | // State for cycling mode |
| 3122 | let cycleMode = false; |
| 3123 | let cycleIndex = 0; |
| 3124 | let isHovered = false; |
| 3125 | let prevBtn, nextBtn; |
| 3126 | |
| 3127 | function updateCycleText() { |
| 3128 | const e = entries[cycleIndex]; |
| 3129 | textSpan.textContent = isHovered ? e.detail : e.name; |
| 3130 | } |
| 3131 | |
| 3132 | function enableCycleMode() { |
| 3133 | if (cycleMode || entries.length < 2) return; |
| 3134 | cycleMode = true; |
| 3135 | |
| 3136 | const btnStyle = { |
| 3137 | background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', |
| 3138 | fontSize: '11px', cursor: 'pointer', padding: '3px 4px', |
| 3139 | fontFamily: 'system-ui, sans-serif', lineHeight: '14px', |
| 3140 | pointerEvents: 'auto', |
| 3141 | }; |
| 3142 | |
| 3143 | const navGroup = document.createElement('span'); |
| 3144 | Object.assign(navGroup.style, { |
| 3145 | display: 'inline-flex', alignItems: 'center', flexShrink: '0', |
| 3146 | }); |
| 3147 | |
| 3148 | prevBtn = document.createElement('button'); |
| 3149 | prevBtn.textContent = '\u2039'; |
| 3150 | Object.assign(prevBtn.style, btnStyle); |
| 3151 | prevBtn.style.paddingLeft = '6px'; |
| 3152 | prevBtn.addEventListener('click', (e) => { |
| 3153 | e.stopPropagation(); |
| 3154 | cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; |
| 3155 | updateCycleText(); |
| 3156 | }); |
| 3157 | |
| 3158 | nextBtn = document.createElement('button'); |
| 3159 | nextBtn.textContent = '\u203A'; |
| 3160 | Object.assign(nextBtn.style, btnStyle); |
| 3161 | nextBtn.style.paddingRight = '2px'; |
| 3162 | nextBtn.addEventListener('click', (e) => { |
| 3163 | e.stopPropagation(); |
| 3164 | cycleIndex = (cycleIndex + 1) % entries.length; |
| 3165 | updateCycleText(); |
| 3166 | }); |
| 3167 | |
| 3168 | navGroup.appendChild(prevBtn); |
| 3169 | navGroup.appendChild(nextBtn); |
| 3170 | label.insertBefore(navGroup, textSpan); |
| 3171 | textSpan.style.padding = '3px 8px 3px 4px'; |
| 3172 | updateCycleText(); |
| 3173 | } |
| 3174 | |
| 3175 | outline.appendChild(label); |
| 3176 | |
| 3177 | // Start hidden; the IntersectionObserver will show it once the target is rendered |
| 3178 | outline.style.display = 'none'; |
| 3179 | outline._staggerIndex = overlayIndex++; |
| 3180 | el._impeccableOverlay = outline; |
| 3181 | visibilityObserver.observe(el); |
| 3182 | |
| 3183 | // After first paint, check label width vs outline |
| 3184 | outline._checkLabel = () => { |
| 3185 | if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { |
| 3186 | enableCycleMode(); |
| 3187 | } |
| 3188 | }; |
| 3189 | |
| 3190 | // Hover: show detail text, darken |
| 3191 | const onMouseEnter = () => { |
| 3192 | isHovered = true; |
| 3193 | outline.classList.add('impeccable-hover'); |
| 3194 | outline.style.outlineColor = BRAND_COLOR_HOVER; |
| 3195 | label.style.background = BRAND_COLOR_HOVER; |
| 3196 | if (cycleMode) { |
| 3197 | updateCycleText(); |
| 3198 | } else { |
| 3199 | textSpan.textContent = entries.map(e => e.detail).join(' | '); |
| 3200 | } |
| 3201 | }; |
| 3202 | const onMouseLeave = () => { |
| 3203 | isHovered = false; |
| 3204 | outline.classList.remove('impeccable-hover'); |
| 3205 | outline.style.outlineColor = ''; |
| 3206 | label.style.background = LABEL_BG; |
| 3207 | if (cycleMode) { |
| 3208 | updateCycleText(); |
| 3209 | } else { |
| 3210 | textSpan.textContent = allText; |
| 3211 | } |
| 3212 | }; |
| 3213 | el.addEventListener('mouseenter', onMouseEnter); |
| 3214 | el.addEventListener('mouseleave', onMouseLeave); |
| 3215 | outline._cleanup = () => { |
| 3216 | el.removeEventListener('mouseenter', onMouseEnter); |
| 3217 | el.removeEventListener('mouseleave', onMouseLeave); |
| 3218 | }; |
| 3219 | |
| 3220 | document.body.appendChild(outline); |
| 3221 | overlays.push(outline); |
| 3222 | }; |
| 3223 | |
| 3224 | const showPageBanner = function(findings) { |
| 3225 | if (!findings.length) return; |
| 3226 | const banner = document.createElement('div'); |
| 3227 | banner.className = 'impeccable-overlay impeccable-banner'; |
| 3228 | Object.assign(banner.style, { |
| 3229 | position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', |
| 3230 | background: LABEL_BG, color: LABEL_INK, |
| 3231 | fontFamily: 'system-ui, sans-serif', fontSize: '13px', |
| 3232 | display: 'flex', alignItems: 'center', pointerEvents: 'auto', |
| 3233 | height: '36px', overflow: 'hidden', maxWidth: '100vw', |
| 3234 | transform: 'translateY(-100%)', |
| 3235 | transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', |
| 3236 | }); |
| 3237 | requestAnimationFrame(() => requestAnimationFrame(() => { |
| 3238 | banner.style.transform = 'translateY(0)'; |
| 3239 | })); |
| 3240 | |
| 3241 | // Scrollable findings area |
| 3242 | const scrollArea = document.createElement('div'); |
| 3243 | Object.assign(scrollArea.style, { |
| 3244 | flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', |
| 3245 | display: 'flex', gap: '8px', alignItems: 'center', |
| 3246 | padding: '0 12px', scrollSnapType: 'x mandatory', |
| 3247 | scrollbarWidth: 'none', |
| 3248 | }); |
| 3249 | for (const f of findings) { |
| 3250 | const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; |
| 3251 | const tag = document.createElement('span'); |
| 3252 | tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; |
| 3253 | Object.assign(tag.style, { |
| 3254 | background: 'rgba(255,255,255,0.15)', padding: '2px 8px', |
| 3255 | borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', |
| 3256 | whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', |
| 3257 | }); |
| 3258 | scrollArea.appendChild(tag); |
| 3259 | } |
| 3260 | banner.appendChild(scrollArea); |
| 3261 | |
| 3262 | // Controls area (only in standalone mode, not extension) |
| 3263 | if (!EXTENSION_MODE) { |
| 3264 | const controls = document.createElement('div'); |
| 3265 | Object.assign(controls.style, { |
| 3266 | display: 'flex', alignItems: 'center', gap: '2px', |
| 3267 | padding: '0 8px', flexShrink: '0', |
| 3268 | }); |
| 3269 | |
| 3270 | // Toggle visibility button |
| 3271 | const toggle = document.createElement('button'); |
| 3272 | toggle.textContent = '\u25C9'; // circle with dot (visible state) |
| 3273 | toggle.title = 'Toggle overlay visibility'; |
| 3274 | Object.assign(toggle.style, { |
| 3275 | background: 'none', border: 'none', |
| 3276 | color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', |
| 3277 | opacity: '0.85', transition: 'opacity 0.15s', |
| 3278 | }); |
| 3279 | let overlaysVisible = true; |
| 3280 | toggle.addEventListener('click', () => { |
| 3281 | overlaysVisible = !overlaysVisible; |
| 3282 | document.body.classList.toggle('impeccable-hidden', !overlaysVisible); |
| 3283 | toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle |
| 3284 | toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; |
| 3285 | }); |
| 3286 | controls.appendChild(toggle); |
| 3287 | |
| 3288 | // Close button |
| 3289 | const close = document.createElement('button'); |
| 3290 | close.textContent = '\u00d7'; |
| 3291 | close.title = 'Dismiss banner'; |
| 3292 | Object.assign(close.style, { |
| 3293 | background: 'none', border: 'none', |
| 3294 | color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', |
| 3295 | }); |
| 3296 | close.addEventListener('click', () => banner.remove()); |
| 3297 | controls.appendChild(close); |
| 3298 | |
| 3299 | banner.appendChild(controls); |
| 3300 | } |
| 3301 | document.body.appendChild(banner); |
| 3302 | overlays.push(banner); |
| 3303 | }; |
| 3304 | |
| 3305 | // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". |
| 3306 | // These change between builds and produce brittle, ugly selectors. |
| 3307 | function isLikelyHashedClass(c) { |
| 3308 | if (!c) return true; |
| 3309 | if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; |
| 3310 | if (/^_[\w-]{5,}$/.test(c)) return true; |
| 3311 | if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; |
| 3312 | return false; |
| 3313 | } |
| 3314 | |
| 3315 | function buildSelectorSegment(el) { |
| 3316 | const tag = el.tagName.toLowerCase(); |
| 3317 | let sel = tag; |
| 3318 | |
| 3319 | if (el.classList && el.classList.length > 0) { |
| 3320 | const classes = [...el.classList] |
| 3321 | .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) |
| 3322 | .slice(0, 2); |
| 3323 | if (classes.length > 0) { |
| 3324 | sel += '.' + classes.map(c => CSS.escape(c)).join('.'); |
| 3325 | } |
| 3326 | } |
| 3327 | |
| 3328 | // Disambiguate among siblings only if the parent has multiple matches |
| 3329 | const parent = el.parentElement; |
| 3330 | if (parent) { |
| 3331 | try { |
| 3332 | const matching = parent.querySelectorAll(':scope > ' + sel); |
| 3333 | if (matching.length > 1) { |
| 3334 | const sameType = [...parent.children].filter(c => c.tagName === el.tagName); |
| 3335 | const idx = sameType.indexOf(el) + 1; |
| 3336 | sel += `:nth-of-type(${idx})`; |
| 3337 | } |
| 3338 | } catch { |
| 3339 | const idx = [...parent.children].indexOf(el) + 1; |
| 3340 | sel = `${tag}:nth-child(${idx})`; |
| 3341 | } |
| 3342 | } |
| 3343 | return sel; |
| 3344 | } |
| 3345 | |
| 3346 | function generateSelector(el) { |
| 3347 | if (el === document.body) return 'body'; |
| 3348 | if (el === document.documentElement) return 'html'; |
| 3349 | if (el.id) return '#' + CSS.escape(el.id); |
| 3350 | |
| 3351 | const parts = []; |
| 3352 | let current = el; |
| 3353 | let depth = 0; |
| 3354 | const MAX_DEPTH = 10; |
| 3355 | |
| 3356 | while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { |
| 3357 | parts.unshift(buildSelectorSegment(current)); |
| 3358 | |
| 3359 | // Anchor on an ancestor's ID and stop walking up |
| 3360 | if (current.id) { |
| 3361 | parts[0] = '#' + CSS.escape(current.id); |
| 3362 | break; |
| 3363 | } |
| 3364 | |
| 3365 | // Stop as soon as the partial selector uniquely identifies the target |
| 3366 | const trySelector = parts.join(' > '); |
| 3367 | try { |
| 3368 | const matches = document.querySelectorAll(trySelector); |
| 3369 | if (matches.length === 1 && matches[0] === el) { |
| 3370 | return trySelector; |
| 3371 | } |
| 3372 | } catch { /* invalid selector — keep walking */ } |
| 3373 | |
| 3374 | current = current.parentElement; |
| 3375 | depth++; |
| 3376 | } |
| 3377 | |
| 3378 | return parts.join(' > '); |
| 3379 | } |
| 3380 | |
| 3381 | function getDirectText(el) { |
| 3382 | return [...el.childNodes] |
| 3383 | .filter(n => n.nodeType === 3) |
| 3384 | .map(n => n.textContent || '') |
| 3385 | .join(''); |
| 3386 | } |
| 3387 | |
| 3388 | function getDirectTextRect(el) { |
| 3389 | const rects = []; |
| 3390 | for (const node of el.childNodes) { |
| 3391 | if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; |
| 3392 | const range = document.createRange(); |
| 3393 | range.selectNodeContents(node); |
| 3394 | for (const rect of range.getClientRects()) { |
| 3395 | if (rect.width >= 1 && rect.height >= 1) rects.push(rect); |
| 3396 | } |
| 3397 | range.detach?.(); |
| 3398 | } |
| 3399 | if (rects.length === 0) return null; |
| 3400 | const left = Math.min(...rects.map(r => r.left)); |
| 3401 | const top = Math.min(...rects.map(r => r.top)); |
| 3402 | const right = Math.max(...rects.map(r => r.right)); |
| 3403 | const bottom = Math.max(...rects.map(r => r.bottom)); |
| 3404 | return { |
| 3405 | left, |
| 3406 | top, |
| 3407 | right, |
| 3408 | bottom, |
| 3409 | width: right - left, |
| 3410 | height: bottom - top, |
| 3411 | x: left, |
| 3412 | y: top, |
| 3413 | }; |
| 3414 | } |
| 3415 | |
| 3416 | function collectVisualContrastReasons(el, style) { |
| 3417 | const reasons = new Set(); |
| 3418 | const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; |
| 3419 | const ownBgImage = style.backgroundImage || ''; |
| 3420 | if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { |
| 3421 | reasons.add('background-clip text'); |
| 3422 | } |
| 3423 | if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); |
| 3424 | |
| 3425 | let current = el; |
| 3426 | while (current && current.nodeType === 1) { |
| 3427 | const tag = current.tagName?.toLowerCase(); |
| 3428 | const currentStyle = getComputedStyle(current); |
| 3429 | const bgImage = currentStyle.backgroundImage || ''; |
| 3430 | const isDocumentSurface = tag === 'body' || tag === 'html'; |
| 3431 | |
| 3432 | if (!isDocumentSurface && bgImage && bgImage !== 'none') { |
| 3433 | if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); |
| 3434 | if (/gradient/i.test(bgImage)) reasons.add('gradient background'); |
| 3435 | } |
| 3436 | if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); |
| 3437 | if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); |
| 3438 | if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); |
| 3439 | if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); |
| 3440 | |
| 3441 | const solidBg = parseRgb(currentStyle.backgroundColor); |
| 3442 | if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; |
| 3443 | current = current.parentElement; |
| 3444 | } |
| 3445 | |
| 3446 | const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 3447 | if (sampleRect && document.elementsFromPoint) { |
| 3448 | const points = [ |
| 3449 | [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], |
| 3450 | [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], |
| 3451 | [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], |
| 3452 | ]; |
| 3453 | for (const [x, y] of points) { |
| 3454 | if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; |
| 3455 | const stack = document.elementsFromPoint(x, y); |
| 3456 | const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); |
| 3457 | if (selfIndex < 0) continue; |
| 3458 | for (const node of stack.slice(selfIndex + 1)) { |
| 3459 | const nodeTag = node.tagName?.toLowerCase(); |
| 3460 | if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { |
| 3461 | reasons.add(`${nodeTag} underlay`); |
| 3462 | break; |
| 3463 | } |
| 3464 | } |
| 3465 | } |
| 3466 | } |
| 3467 | |
| 3468 | return [...reasons]; |
| 3469 | } |
| 3470 | |
| 3471 | function collectVisualContrastCandidates(options = {}) { |
| 3472 | const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; |
| 3473 | const candidates = []; |
| 3474 | for (const el of document.querySelectorAll('*')) { |
| 3475 | if (candidates.length >= maxCandidates) break; |
| 3476 | if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 3477 | if (el.closest('[id^="impeccable-live-"]')) continue; |
| 3478 | if (el === document.body || el === document.documentElement) continue; |
| 3479 | |
| 3480 | const tag = el.tagName.toLowerCase(); |
| 3481 | const style = getComputedStyle(el); |
| 3482 | if (style.display === 'none' || style.visibility === 'hidden') continue; |
| 3483 | const directText = getDirectText(el); |
| 3484 | const hasDirectText = directText.trim().length > 0; |
| 3485 | if (!hasDirectText || isEmojiOnlyText(directText)) continue; |
| 3486 | |
| 3487 | const bgColor = readOwnBackgroundColor(el, style); |
| 3488 | const isStyledButton = (tag === 'a' || tag === 'button') |
| 3489 | && bgColor && bgColor.a > 0.5; |
| 3490 | if (SAFE_TAGS.has(tag) && !isStyledButton) continue; |
| 3491 | |
| 3492 | const rect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 3493 | if (!rect || rect.width < 4 || rect.height < 4) continue; |
| 3494 | |
| 3495 | const reasons = collectVisualContrastReasons(el, style); |
| 3496 | if (reasons.length === 0) continue; |
| 3497 | |
| 3498 | const textColor = parseRgb(style.color); |
| 3499 | const fontSize = parseFloat(style.fontSize) || 16; |
| 3500 | const fontWeight = parseInt(style.fontWeight) || 400; |
| 3501 | const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); |
| 3502 | const threshold = isLargeText ? 3.0 : 4.5; |
| 3503 | const clip = { |
| 3504 | x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), |
| 3505 | y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), |
| 3506 | width: Math.max(1, Math.ceil(rect.width + 4)), |
| 3507 | height: Math.max(1, Math.ceil(rect.height + 4)), |
| 3508 | }; |
| 3509 | |
| 3510 | candidates.push({ |
| 3511 | selector: generateSelector(el), |
| 3512 | tagName: tag, |
| 3513 | text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), |
| 3514 | threshold, |
| 3515 | reasons, |
| 3516 | clip, |
| 3517 | textColor, |
| 3518 | preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => |
| 3519 | reason === 'opacity stack' || |
| 3520 | reason === 'blend mode' || |
| 3521 | reason === 'filter' || |
| 3522 | reason === 'backdrop filter' || |
| 3523 | reason === 'background-clip text' |
| 3524 | ), |
| 3525 | backgroundClipText: reasons.includes('background-clip text'), |
| 3526 | }); |
| 3527 | } |
| 3528 | return candidates; |
| 3529 | } |
| 3530 | |
| 3531 | const visualContrastImageCache = new Map(); |
| 3532 | const visualContrastRasterCache = new WeakMap(); |
| 3533 | |
| 3534 | function clampByte(value) { |
| 3535 | return Math.max(0, Math.min(255, Math.round(value))); |
| 3536 | } |
| 3537 | |
| 3538 | function blendRgba(fg, bg) { |
| 3539 | if (!fg) return bg || null; |
| 3540 | if (!bg || fg.a == null || fg.a >= 0.999) { |
| 3541 | return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; |
| 3542 | } |
| 3543 | const alpha = Math.max(0, Math.min(1, fg.a)); |
| 3544 | return { |
| 3545 | r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), |
| 3546 | g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), |
| 3547 | b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), |
| 3548 | a: 1, |
| 3549 | }; |
| 3550 | } |
| 3551 | |
| 3552 | function pickWorstContrastColor(textColor, colors) { |
| 3553 | const usable = (colors || []).filter(Boolean); |
| 3554 | if (!usable.length) return null; |
| 3555 | let worst = usable[0]; |
| 3556 | let worstRatio = contrastRatio(textColor, worst); |
| 3557 | for (const color of usable.slice(1)) { |
| 3558 | const ratio = contrastRatio(textColor, color); |
| 3559 | if (ratio < worstRatio) { |
| 3560 | worst = color; |
| 3561 | worstRatio = ratio; |
| 3562 | } |
| 3563 | } |
| 3564 | return worst; |
| 3565 | } |
| 3566 | |
| 3567 | function firstCssUrl(value) { |
| 3568 | const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); |
| 3569 | if (!match) return ''; |
| 3570 | return (match[1] || match[2] || match[3] || '').trim(); |
| 3571 | } |
| 3572 | |
| 3573 | function getLayerValue(value, index = 0) { |
| 3574 | return String(value || '').split(',')[index]?.trim() || ''; |
| 3575 | } |
| 3576 | |
| 3577 | function parsePositionToken(token, container, painted) { |
| 3578 | if (!token || token === 'center') return (container - painted) / 2; |
| 3579 | if (token === 'left' || token === 'top') return 0; |
| 3580 | if (token === 'right' || token === 'bottom') return container - painted; |
| 3581 | if (/%$/.test(token)) { |
| 3582 | const pct = parseFloat(token) / 100; |
| 3583 | return (container - painted) * pct; |
| 3584 | } |
| 3585 | if (/px$/.test(token)) return parseFloat(token) || 0; |
| 3586 | return (container - painted) / 2; |
| 3587 | } |
| 3588 | |
| 3589 | function parsePositionPair(positionValue) { |
| 3590 | const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); |
| 3591 | const first = tokens[0] || '50%'; |
| 3592 | if (tokens.length < 2) { |
| 3593 | if (first === 'top' || first === 'bottom') return ['50%', first]; |
| 3594 | return [first, '50%']; |
| 3595 | } |
| 3596 | return [first, tokens[1] || '50%']; |
| 3597 | } |
| 3598 | |
| 3599 | function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { |
| 3600 | const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; |
| 3601 | const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; |
| 3602 | let paintedWidth = intrinsicWidth; |
| 3603 | let paintedHeight = intrinsicHeight; |
| 3604 | const size = String(sizeValue || 'auto').trim(); |
| 3605 | |
| 3606 | if (size === 'cover' || size === 'contain') { |
| 3607 | const scale = size === 'cover' |
| 3608 | ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) |
| 3609 | : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); |
| 3610 | paintedWidth = intrinsicWidth * scale; |
| 3611 | paintedHeight = intrinsicHeight * scale; |
| 3612 | } else if (size && size !== 'auto') { |
| 3613 | const parts = size.split(/\s+/); |
| 3614 | const widthToken = parts[0]; |
| 3615 | const heightToken = parts[1] || 'auto'; |
| 3616 | if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); |
| 3617 | else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; |
| 3618 | if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); |
| 3619 | else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); |
| 3620 | else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; |
| 3621 | } |
| 3622 | |
| 3623 | const [xToken, yToken] = parsePositionPair(positionValue); |
| 3624 | const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); |
| 3625 | const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); |
| 3626 | return { |
| 3627 | left: containerRect.left + positionX, |
| 3628 | top: containerRect.top + positionY, |
| 3629 | width: paintedWidth, |
| 3630 | height: paintedHeight, |
| 3631 | intrinsicWidth, |
| 3632 | intrinsicHeight, |
| 3633 | }; |
| 3634 | } |
| 3635 | |
| 3636 | function parseObjectPosition(positionValue) { |
| 3637 | return parsePositionPair(positionValue); |
| 3638 | } |
| 3639 | |
| 3640 | function resolveObjectImageRect(containerRect, image, style) { |
| 3641 | const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; |
| 3642 | const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; |
| 3643 | const fit = style.objectFit || 'fill'; |
| 3644 | let paintedWidth = containerRect.width; |
| 3645 | let paintedHeight = containerRect.height; |
| 3646 | if (fit === 'contain' || fit === 'cover') { |
| 3647 | const scale = fit === 'cover' |
| 3648 | ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) |
| 3649 | : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); |
| 3650 | paintedWidth = intrinsicWidth * scale; |
| 3651 | paintedHeight = intrinsicHeight * scale; |
| 3652 | } else if (fit === 'none') { |
| 3653 | paintedWidth = intrinsicWidth; |
| 3654 | paintedHeight = intrinsicHeight; |
| 3655 | } else if (fit === 'scale-down') { |
| 3656 | const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); |
| 3657 | paintedWidth = intrinsicWidth * containScale; |
| 3658 | paintedHeight = intrinsicHeight * containScale; |
| 3659 | } |
| 3660 | const [xToken, yToken] = parseObjectPosition(style.objectPosition); |
| 3661 | return { |
| 3662 | left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), |
| 3663 | top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), |
| 3664 | width: paintedWidth, |
| 3665 | height: paintedHeight, |
| 3666 | intrinsicWidth, |
| 3667 | intrinsicHeight, |
| 3668 | }; |
| 3669 | } |
| 3670 | |
| 3671 | function pointToImageSource(point, paintedRect) { |
| 3672 | if ( |
| 3673 | point.x < paintedRect.left || |
| 3674 | point.y < paintedRect.top || |
| 3675 | point.x > paintedRect.left + paintedRect.width || |
| 3676 | point.y > paintedRect.top + paintedRect.height |
| 3677 | ) { |
| 3678 | return null; |
| 3679 | } |
| 3680 | return { |
| 3681 | x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), |
| 3682 | y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), |
| 3683 | }; |
| 3684 | } |
| 3685 | |
| 3686 | async function loadVisualContrastImage(src) { |
| 3687 | if (!src) return null; |
| 3688 | if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); |
| 3689 | const promise = new Promise(resolve => { |
| 3690 | const img = new Image(); |
| 3691 | let settled = false; |
| 3692 | const finish = value => { |
| 3693 | if (settled) return; |
| 3694 | settled = true; |
| 3695 | clearTimeout(timer); |
| 3696 | resolve(value); |
| 3697 | }; |
| 3698 | const timer = setTimeout(() => finish(null), 800); |
| 3699 | try { |
| 3700 | const absolute = new URL(src, location.href); |
| 3701 | if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { |
| 3702 | img.crossOrigin = 'anonymous'; |
| 3703 | } |
| 3704 | } catch { |
| 3705 | // Let the browser resolve unusual URLs itself. |
| 3706 | } |
| 3707 | img.onload = () => finish(img); |
| 3708 | img.onerror = () => finish(null); |
| 3709 | img.src = src; |
| 3710 | }); |
| 3711 | visualContrastImageCache.set(src, promise); |
| 3712 | return promise; |
| 3713 | } |
| 3714 | |
| 3715 | function sampleDrawablePixel(drawable, sourcePoint) { |
| 3716 | if (visualContrastRasterCache.has(drawable)) { |
| 3717 | const cached = visualContrastRasterCache.get(drawable); |
| 3718 | if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; |
| 3719 | try { |
| 3720 | const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); |
| 3721 | const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); |
| 3722 | const data = cached.ctx.getImageData(x, y, 1, 1).data; |
| 3723 | return { |
| 3724 | status: 'sampled', |
| 3725 | color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, |
| 3726 | }; |
| 3727 | } catch (err) { |
| 3728 | return { |
| 3729 | status: 'unresolved', |
| 3730 | reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', |
| 3731 | }; |
| 3732 | } |
| 3733 | } |
| 3734 | |
| 3735 | const canvas = document.createElement('canvas'); |
| 3736 | const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; |
| 3737 | const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; |
| 3738 | const maxRasterSide = 640; |
| 3739 | const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); |
| 3740 | canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); |
| 3741 | canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); |
| 3742 | const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| 3743 | if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; |
| 3744 | try { |
| 3745 | ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); |
| 3746 | const cached = { |
| 3747 | ctx, |
| 3748 | width: canvas.width, |
| 3749 | height: canvas.height, |
| 3750 | scaleX: canvas.width / intrinsicWidth, |
| 3751 | scaleY: canvas.height / intrinsicHeight, |
| 3752 | }; |
| 3753 | visualContrastRasterCache.set(drawable, cached); |
| 3754 | const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); |
| 3755 | const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); |
| 3756 | const data = ctx.getImageData(x, y, 1, 1).data; |
| 3757 | return { |
| 3758 | status: 'sampled', |
| 3759 | color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, |
| 3760 | }; |
| 3761 | } catch (err) { |
| 3762 | const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; |
| 3763 | visualContrastRasterCache.set(drawable, { ctx: null, reason }); |
| 3764 | return { |
| 3765 | status: 'unresolved', |
| 3766 | reason, |
| 3767 | }; |
| 3768 | } |
| 3769 | } |
| 3770 | |
| 3771 | async function sampleCssBackground(el, style, point, textColor) { |
| 3772 | const rect = el.getBoundingClientRect(); |
| 3773 | const bgImage = style.backgroundImage || ''; |
| 3774 | if (bgImage && bgImage !== 'none') { |
| 3775 | if (/gradient/i.test(bgImage)) { |
| 3776 | const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); |
| 3777 | if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; |
| 3778 | } |
| 3779 | if (/url\s*\(/i.test(bgImage)) { |
| 3780 | const img = await loadVisualContrastImage(firstCssUrl(bgImage)); |
| 3781 | if (!img) return { status: 'unresolved', reason: 'image unavailable' }; |
| 3782 | const paintedRect = resolvePaintedImageRect( |
| 3783 | rect, |
| 3784 | img, |
| 3785 | getLayerValue(style.backgroundSize) || 'auto', |
| 3786 | getLayerValue(style.backgroundPosition) || '50% 50%', |
| 3787 | ); |
| 3788 | const sourcePoint = pointToImageSource(point, paintedRect); |
| 3789 | if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; |
| 3790 | const sample = sampleDrawablePixel(img, sourcePoint); |
| 3791 | if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; |
| 3792 | return sample; |
| 3793 | } |
| 3794 | } |
| 3795 | const bg = parseRgb(style.backgroundColor); |
| 3796 | if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; |
| 3797 | return { status: 'unresolved', reason: 'no readable background' }; |
| 3798 | } |
| 3799 | |
| 3800 | async function sampleImageElement(img, point) { |
| 3801 | const rect = img.getBoundingClientRect(); |
| 3802 | const style = getComputedStyle(img); |
| 3803 | const paintedRect = resolveObjectImageRect(rect, img, style); |
| 3804 | const sourcePoint = pointToImageSource(point, paintedRect); |
| 3805 | if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; |
| 3806 | const sample = sampleDrawablePixel(img, sourcePoint); |
| 3807 | if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; |
| 3808 | |
| 3809 | if (img.currentSrc || img.src) { |
| 3810 | const loaded = await loadVisualContrastImage(img.currentSrc || img.src); |
| 3811 | if (loaded) { |
| 3812 | const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; |
| 3813 | const loadedPoint = pointToImageSource(point, loadedRect); |
| 3814 | if (loadedPoint) { |
| 3815 | const loadedSample = sampleDrawablePixel(loaded, loadedPoint); |
| 3816 | if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; |
| 3817 | } |
| 3818 | } |
| 3819 | } |
| 3820 | return sample; |
| 3821 | } |
| 3822 | |
| 3823 | function textSamplePoints(rect) { |
| 3824 | const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); |
| 3825 | const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); |
| 3826 | const xs = rect.width < 28 |
| 3827 | ? [rect.left + rect.width / 2] |
| 3828 | : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; |
| 3829 | const ys = rect.height < 22 |
| 3830 | ? [rect.top + rect.height / 2] |
| 3831 | : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; |
| 3832 | const points = []; |
| 3833 | for (const y of ys) { |
| 3834 | for (const x of xs) { |
| 3835 | if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); |
| 3836 | } |
| 3837 | } |
| 3838 | return points; |
| 3839 | } |
| 3840 | |
| 3841 | async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { |
| 3842 | if (depth > 8) { |
| 3843 | return { status: 'unresolved', reason: 'background stack too deep' }; |
| 3844 | } |
| 3845 | const stack = typeof document.elementsFromPoint === 'function' |
| 3846 | ? document.elementsFromPoint(point.x, point.y) |
| 3847 | : []; |
| 3848 | const selfIndex = stack.findIndex(node => node === el || el.contains(node)); |
| 3849 | const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; |
| 3850 | const unresolved = []; |
| 3851 | |
| 3852 | for (const node of nodes) { |
| 3853 | if (!node || node.nodeType !== 1) continue; |
| 3854 | if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 3855 | const tag = node.tagName?.toLowerCase(); |
| 3856 | if (tag === 'img') { |
| 3857 | const sample = await sampleImageElement(node, point); |
| 3858 | if (sample.status === 'sampled') return sample; |
| 3859 | unresolved.push(sample.reason); |
| 3860 | continue; |
| 3861 | } |
| 3862 | if (tag === 'canvas' || tag === 'video') { |
| 3863 | const rect = node.getBoundingClientRect(); |
| 3864 | const sourcePoint = pointToImageSource(point, { |
| 3865 | left: rect.left, |
| 3866 | top: rect.top, |
| 3867 | width: rect.width, |
| 3868 | height: rect.height, |
| 3869 | intrinsicWidth: node.width || node.videoWidth || rect.width, |
| 3870 | intrinsicHeight: node.height || node.videoHeight || rect.height, |
| 3871 | }); |
| 3872 | if (sourcePoint) { |
| 3873 | const sample = sampleDrawablePixel(node, sourcePoint); |
| 3874 | if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; |
| 3875 | unresolved.push(sample.reason); |
| 3876 | } |
| 3877 | continue; |
| 3878 | } |
| 3879 | const style = getComputedStyle(node); |
| 3880 | const sample = await sampleCssBackground(node, style, point, textColor); |
| 3881 | if (sample.status === 'sampled') { |
| 3882 | if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; |
| 3883 | const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); |
| 3884 | if (under.status === 'sampled') { |
| 3885 | return { |
| 3886 | status: 'sampled', |
| 3887 | color: blendRgba(sample.color, under.color), |
| 3888 | method: `${sample.method}+alpha`, |
| 3889 | }; |
| 3890 | } |
| 3891 | return sample; |
| 3892 | } |
| 3893 | unresolved.push(sample.reason); |
| 3894 | } |
| 3895 | |
| 3896 | return { |
| 3897 | status: 'unresolved', |
| 3898 | reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', |
| 3899 | }; |
| 3900 | } |
| 3901 | |
| 3902 | async function analyzeVisualContrastCandidate(candidate) { |
| 3903 | let el; |
| 3904 | try { |
| 3905 | el = document.querySelector(candidate.selector); |
| 3906 | } catch { |
| 3907 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; |
| 3908 | } |
| 3909 | if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; |
| 3910 | |
| 3911 | const blockingReason = (candidate.reasons || []).find(reason => |
| 3912 | reason === 'background-clip text' || |
| 3913 | reason === 'blend mode' || |
| 3914 | reason === 'filter' || |
| 3915 | reason === 'backdrop filter' || |
| 3916 | reason === 'opacity stack' || |
| 3917 | reason === 'text shadow' |
| 3918 | ); |
| 3919 | if (blockingReason) { |
| 3920 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; |
| 3921 | } |
| 3922 | |
| 3923 | const style = getComputedStyle(el); |
| 3924 | const textColor = parseRgb(style.color) || candidate.textColor; |
| 3925 | if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; |
| 3926 | |
| 3927 | const rect = getDirectTextRect(el) || el.getBoundingClientRect(); |
| 3928 | if (!rect || rect.width < 4 || rect.height < 4) { |
| 3929 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; |
| 3930 | } |
| 3931 | |
| 3932 | const points = textSamplePoints(rect); |
| 3933 | if (points.length === 0) { |
| 3934 | return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; |
| 3935 | } |
| 3936 | |
| 3937 | const ratios = []; |
| 3938 | const methods = new Set(); |
| 3939 | const unresolved = []; |
| 3940 | for (const point of points) { |
| 3941 | const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); |
| 3942 | if (sample.status !== 'sampled' || !sample.color) { |
| 3943 | unresolved.push(sample.reason); |
| 3944 | continue; |
| 3945 | } |
| 3946 | const fg = blendRgba(textColor, sample.color); |
| 3947 | ratios.push(contrastRatio(fg, sample.color)); |
| 3948 | if (sample.method) methods.add(sample.method); |
| 3949 | } |
| 3950 | |
| 3951 | if (ratios.length < Math.min(3, points.length)) { |
| 3952 | return { |
| 3953 | ...candidate, |
| 3954 | status: 'unresolved', |
| 3955 | confidence: 'none', |
| 3956 | samples: ratios.length, |
| 3957 | reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', |
| 3958 | }; |
| 3959 | } |
| 3960 | |
| 3961 | ratios.sort((a, b) => a - b); |
| 3962 | const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; |
| 3963 | const measuredRatio = pick(10); |
| 3964 | const medianRatio = pick(50); |
| 3965 | const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; |
| 3966 | const method = [...methods].sort().join(', ') || 'browser-visual'; |
| 3967 | const textLabel = candidate.text ? ` "${candidate.text}"` : ''; |
| 3968 | const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; |
| 3969 | return { |
| 3970 | ...candidate, |
| 3971 | status, |
| 3972 | confidence: method.includes('canvas-') ? 'high' : 'medium', |
| 3973 | method, |
| 3974 | ratio: measuredRatio, |
| 3975 | medianRatio, |
| 3976 | samples: ratios.length, |
| 3977 | finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, |
| 3978 | }; |
| 3979 | } |
| 3980 | |
| 3981 | function waitForVisualPaint() { |
| 3982 | return new Promise(resolve => { |
| 3983 | requestAnimationFrame(() => requestAnimationFrame(resolve)); |
| 3984 | }); |
| 3985 | } |
| 3986 | |
| 3987 | async function analyzeVisualContrast(options = {}) { |
| 3988 | const candidates = collectVisualContrastCandidates(options); |
| 3989 | const results = []; |
| 3990 | const shouldScrollOffscreen = options.scrollOffscreen === true; |
| 3991 | const restoreScroll = { x: window.scrollX, y: window.scrollY }; |
| 3992 | for (const candidate of candidates) { |
| 3993 | if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { |
| 3994 | window.scrollTo(restoreScroll.x, restoreScroll.y); |
| 3995 | await waitForVisualPaint(); |
| 3996 | } |
| 3997 | let result = await analyzeVisualContrastCandidate(candidate); |
| 3998 | if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { |
| 3999 | let el = null; |
| 4000 | try { |
| 4001 | el = document.querySelector(candidate.selector); |
| 4002 | } catch { |
| 4003 | el = null; |
| 4004 | } |
| 4005 | if (el && typeof el.scrollIntoView === 'function') { |
| 4006 | el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); |
| 4007 | await waitForVisualPaint(); |
| 4008 | result = await analyzeVisualContrastCandidate(candidate); |
| 4009 | } |
| 4010 | } |
| 4011 | results.push(result); |
| 4012 | } |
| 4013 | if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { |
| 4014 | window.scrollTo(restoreScroll.x, restoreScroll.y); |
| 4015 | } |
| 4016 | return results; |
| 4017 | } |
| 4018 | |
| 4019 | function isElementHidden(el) { |
| 4020 | if (!el || el === document.body || el === document.documentElement) return false; |
| 4021 | if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); |
| 4022 | // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) |
| 4023 | return el.offsetWidth === 0 && el.offsetHeight === 0; |
| 4024 | } |
| 4025 | |
| 4026 | function serializeFindings(allFindings) { |
| 4027 | return allFindings.map(({ el, findings }) => ({ |
| 4028 | selector: generateSelector(el), |
| 4029 | tagName: el.tagName?.toLowerCase() || 'unknown', |
| 4030 | rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) |
| 4031 | ? el.getBoundingClientRect().toJSON() : null, |
| 4032 | isPageLevel: el === document.body || el === document.documentElement, |
| 4033 | isHidden: isElementHidden(el), |
| 4034 | findings: findings.map(f => { |
| 4035 | const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); |
| 4036 | return { |
| 4037 | type: f.type || f.id, |
| 4038 | category: ap ? ap.category : 'quality', |
| 4039 | severity: ap?.severity || 'warning', |
| 4040 | detail: f.detail || f.snippet, |
| 4041 | name: ap ? ap.name : (f.type || f.id), |
| 4042 | description: ap ? ap.description : '', |
| 4043 | }; |
| 4044 | }), |
| 4045 | })); |
| 4046 | } |
| 4047 | |
| 4048 | const printSummary = function(allFindings) { |
| 4049 | if (allFindings.length === 0) { |
| 4050 | console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); |
| 4051 | return; |
| 4052 | } |
| 4053 | console.group( |
| 4054 | `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, |
| 4055 | 'color: oklch(84% 0.19 80.46); font-weight: bold' |
| 4056 | ); |
| 4057 | for (const { el, findings } of allFindings) { |
| 4058 | for (const f of findings) { |
| 4059 | console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, |
| 4060 | 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el); |
| 4061 | } |
| 4062 | } |
| 4063 | console.groupEnd(); |
| 4064 | }; |
| 4065 | |
| 4066 | function addBrowserFindings(groupMap, el, findings) { |
| 4067 | if (!findings || findings.length === 0) return; |
| 4068 | const existing = groupMap.get(el); |
| 4069 | if (existing) existing.push(...findings); |
| 4070 | else groupMap.set(el, [...findings]); |
| 4071 | } |
| 4072 | |
| 4073 | function browserFindingsFromMap(groupMap) { |
| 4074 | return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); |
| 4075 | } |
| 4076 | |
| 4077 | function collectBrowserFindings() { |
| 4078 | const groupMap = new Map(); |
| 4079 | const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; |
| 4080 | const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); |
| 4081 | // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a |
| 4082 | // real browser env (detector page, live overlay, extension) running every |
| 4083 | // check is free, so we always surface them; the gating is purely a CLI |
| 4084 | // output concern, applied in the Node engines' detect* return paths. |
| 4085 | |
| 4086 | for (const el of document.querySelectorAll('*')) { |
| 4087 | // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) |
| 4088 | if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; |
| 4089 | // Skip browser extension elements (Claude, etc.) |
| 4090 | const elId = el.id || ''; |
| 4091 | if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; |
| 4092 | // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). |
| 4093 | // These are inspector chrome, not part of the user's design. |
| 4094 | if (el.closest('[id^="impeccable-live-"]')) continue; |
| 4095 | // Skip html/body -- page-level findings go in the banner, not a full-page overlay |
| 4096 | if (el === document.body || el === document.documentElement) continue; |
| 4097 | |
| 4098 | const findings = [ |
| 4099 | ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4100 | ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4101 | ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4102 | ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4103 | ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4104 | ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4105 | ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4106 | ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4107 | ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4108 | ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4109 | ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4110 | ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), |
| 4111 | ].filter(f => _ruleOk(f.type)); |
| 4112 | |
| 4113 | addBrowserFindings(groupMap, el, findings); |
| 4114 | |
| 4115 | // Hero eyebrow: the offending element is the eyebrow above the heading, |
| 4116 | // not the heading itself — highlight the previous sibling instead. |
| 4117 | const eyebrowFindings = checkElementHeroEyebrowDOM(el) |
| 4118 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 4119 | .filter(f => _ruleOk(f.type)); |
| 4120 | if (eyebrowFindings.length > 0 && el.previousElementSibling) { |
| 4121 | addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings); |
| 4122 | } |
| 4123 | } |
| 4124 | |
| 4125 | const pageLevelFindings = []; |
| 4126 | |
| 4127 | const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); |
| 4128 | if (typoFindings.length > 0) { |
| 4129 | pageLevelFindings.push(...typoFindings); |
| 4130 | addBrowserFindings(groupMap, document.body, typoFindings); |
| 4131 | } |
| 4132 | |
| 4133 | const sectionKickerFindings = checkRepeatedSectionKickersDOM() |
| 4134 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 4135 | .filter(f => _ruleOk(f.type)); |
| 4136 | if (sectionKickerFindings.length > 0) { |
| 4137 | pageLevelFindings.push(...sectionKickerFindings); |
| 4138 | addBrowserFindings(groupMap, document.body, sectionKickerFindings); |
| 4139 | } |
| 4140 | |
| 4141 | const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); |
| 4142 | for (const f of layoutFindings) { |
| 4143 | const el = f.el || document.body; |
| 4144 | addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); |
| 4145 | } |
| 4146 | |
| 4147 | // Page-level quality checks (headings, etc.) |
| 4148 | const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); |
| 4149 | if (qualityFindings.length > 0) { |
| 4150 | pageLevelFindings.push(...qualityFindings); |
| 4151 | addBrowserFindings(groupMap, document.body, qualityFindings); |
| 4152 | } |
| 4153 | |
| 4154 | const creamFindings = checkCreamPalette(document) |
| 4155 | .map(f => ({ type: f.id, detail: f.snippet })) |
| 4156 | .filter(f => _ruleOk(f.type)); |
| 4157 | if (creamFindings.length > 0) { |
| 4158 | pageLevelFindings.push(...creamFindings); |
| 4159 | addBrowserFindings(groupMap, document.body, creamFindings); |
| 4160 | } |
| 4161 | |
| 4162 | // Regex-on-HTML checks (shared with Node) |
| 4163 | // Clone the document and strip impeccable-live overlay nodes before the |
| 4164 | // regex scan, so the inspector's own inline styles (transitions on top/ |
| 4165 | // left/width/height, etc.) don't register as page anti-patterns. |
| 4166 | const docClone = document.documentElement.cloneNode(true); |
| 4167 | for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { |
| 4168 | node.remove(); |
| 4169 | } |
| 4170 | const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); |
| 4171 | if (htmlPatternFindings.length > 0) { |
| 4172 | const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); |
| 4173 | pageLevelFindings.push(...mapped); |
| 4174 | addBrowserFindings(groupMap, document.body, mapped); |
| 4175 | } |
| 4176 | |
| 4177 | return { |
| 4178 | groupMap, |
| 4179 | allFindings: browserFindingsFromMap(groupMap), |
| 4180 | pageLevelFindings, |
| 4181 | }; |
| 4182 | } |
| 4183 | |
| 4184 | function shouldRunVisualContrast(options = {}) { |
| 4185 | return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; |
| 4186 | } |
| 4187 | |
| 4188 | function visualContrastOptions(options = {}) { |
| 4189 | const config = window.__IMPECCABLE_CONFIG__ || {}; |
| 4190 | const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' |
| 4191 | ? options.scrollOffscreen |
| 4192 | : typeof options.visualContrastScrollOffscreen === 'boolean' |
| 4193 | ? options.visualContrastScrollOffscreen |
| 4194 | : typeof config.visualContrastScrollOffscreen === 'boolean' |
| 4195 | ? config.visualContrastScrollOffscreen |
| 4196 | : false; |
| 4197 | return { |
| 4198 | ...options, |
| 4199 | maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) |
| 4200 | ? options.visualContrastMaxCandidates |
| 4201 | : Number.isFinite(options.maxCandidates) |
| 4202 | ? options.maxCandidates |
| 4203 | : Number.isFinite(config.visualContrastMaxCandidates) |
| 4204 | ? config.visualContrastMaxCandidates |
| 4205 | : undefined, |
| 4206 | scrollOffscreen, |
| 4207 | }; |
| 4208 | } |
| 4209 | |
| 4210 | let lastVisualContrastAnalyses = []; |
| 4211 | let lazyVisualContrastObserver = null; |
| 4212 | let lazyVisualContrastPending = new WeakMap(); |
| 4213 | const lazyVisualContrastResolving = new WeakSet(); |
| 4214 | let scanGeneration = 0; |
| 4215 | |
| 4216 | function rememberVisualContrastAnalysis(result) { |
| 4217 | if (!result?.selector) { |
| 4218 | lastVisualContrastAnalyses.push(result); |
| 4219 | return; |
| 4220 | } |
| 4221 | const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); |
| 4222 | if (idx >= 0) lastVisualContrastAnalyses[idx] = result; |
| 4223 | else lastVisualContrastAnalyses.push(result); |
| 4224 | } |
| 4225 | |
| 4226 | function disconnectLazyVisualContrastObserver() { |
| 4227 | if (lazyVisualContrastObserver) { |
| 4228 | lazyVisualContrastObserver.disconnect(); |
| 4229 | lazyVisualContrastObserver = null; |
| 4230 | } |
| 4231 | lazyVisualContrastPending = new WeakMap(); |
| 4232 | } |
| 4233 | |
| 4234 | function addVisualContrastResult(groupMap, result, options = {}) { |
| 4235 | if (result.status !== 'fail' || !result.finding || !result.selector) return false; |
| 4236 | let el = null; |
| 4237 | try { |
| 4238 | el = document.querySelector(result.selector); |
| 4239 | } catch { |
| 4240 | el = null; |
| 4241 | } |
| 4242 | if (!el) return false; |
| 4243 | const findingType = result.finding.type || result.finding.id || 'low-contrast'; |
| 4244 | const existing = groupMap.get(el) || []; |
| 4245 | if (existing.some(f => (f.type || f.id) === findingType)) return false; |
| 4246 | addBrowserFindings(groupMap, el, [{ |
| 4247 | type: findingType, |
| 4248 | detail: result.finding.detail || result.finding.snippet, |
| 4249 | }]); |
| 4250 | if (options.decorate && el !== document.body && el !== document.documentElement) { |
| 4251 | highlight(el, groupMap.get(el) || []); |
| 4252 | } |
| 4253 | return true; |
| 4254 | } |
| 4255 | |
| 4256 | function scanResultMeta(options = {}) { |
| 4257 | const scanId = options.scanId; |
| 4258 | if (typeof scanId !== 'string' && typeof scanId !== 'number') return {}; |
| 4259 | return { scanId: String(scanId) }; |
| 4260 | } |
| 4261 | |
| 4262 | function postSerializedFindings(groupMap, options = {}) { |
| 4263 | if (!EXTENSION_MODE) return; |
| 4264 | const allFindings = browserFindingsFromMap(groupMap); |
| 4265 | window.postMessage({ |
| 4266 | source: 'impeccable-results', |
| 4267 | findings: serializeFindings(allFindings), |
| 4268 | count: allFindings.length, |
| 4269 | ...scanResultMeta(options), |
| 4270 | }, '*'); |
| 4271 | } |
| 4272 | |
| 4273 | function postExtensionError(err) { |
| 4274 | if (!EXTENSION_MODE) return; |
| 4275 | window.postMessage({ |
| 4276 | source: 'impeccable-error', |
| 4277 | message: err?.message || String(err), |
| 4278 | }, '*'); |
| 4279 | } |
| 4280 | |
| 4281 | function reportVisualContrastError(err, detail = {}) { |
| 4282 | window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { |
| 4283 | detail: { |
| 4284 | ...detail, |
| 4285 | message: err?.message || String(err), |
| 4286 | }, |
| 4287 | })); |
| 4288 | if (EXTENSION_MODE) { |
| 4289 | postExtensionError(err); |
| 4290 | } else { |
| 4291 | console.warn('[impeccable] visual contrast scan failed', err); |
| 4292 | } |
| 4293 | } |
| 4294 | |
| 4295 | function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { |
| 4296 | disconnectLazyVisualContrastObserver(); |
| 4297 | if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; |
| 4298 | if (typeof IntersectionObserver === 'undefined') return; |
| 4299 | const unresolved = (analyses || []).filter(result => |
| 4300 | result?.status === 'unresolved' && |
| 4301 | result.reason === 'text outside viewport' && |
| 4302 | result.selector |
| 4303 | ); |
| 4304 | if (unresolved.length === 0) return; |
| 4305 | const generation = runtime.generation || scanGeneration; |
| 4306 | |
| 4307 | lazyVisualContrastObserver = new IntersectionObserver((entries) => { |
| 4308 | for (const entry of entries) { |
| 4309 | if (!entry.isIntersecting) continue; |
| 4310 | const el = entry.target; |
| 4311 | const candidate = lazyVisualContrastPending.get(el); |
| 4312 | if (!candidate || lazyVisualContrastResolving.has(el)) continue; |
| 4313 | lazyVisualContrastObserver?.unobserve(el); |
| 4314 | lazyVisualContrastPending.delete(el); |
| 4315 | lazyVisualContrastResolving.add(el); |
| 4316 | waitForVisualPaint() |
| 4317 | .then(() => analyzeVisualContrastCandidate(candidate)) |
| 4318 | .then(result => { |
| 4319 | if (generation !== scanGeneration) return; |
| 4320 | rememberVisualContrastAnalysis(result); |
| 4321 | const added = addVisualContrastResult(groupMap, result, { decorate: true }); |
| 4322 | if (added) { |
| 4323 | postSerializedFindings(groupMap, options); |
| 4324 | window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { |
| 4325 | detail: { |
| 4326 | selector: result.selector, |
| 4327 | status: result.status, |
| 4328 | finding: result.finding || null, |
| 4329 | }, |
| 4330 | })); |
| 4331 | } |
| 4332 | }) |
| 4333 | .catch(err => { |
| 4334 | reportVisualContrastError(err, { selector: candidate.selector }); |
| 4335 | }) |
| 4336 | .finally(() => { |
| 4337 | lazyVisualContrastResolving.delete(el); |
| 4338 | }); |
| 4339 | } |
| 4340 | }, { threshold: 0.5 }); |
| 4341 | |
| 4342 | for (const candidate of unresolved) { |
| 4343 | let el = null; |
| 4344 | try { |
| 4345 | el = document.querySelector(candidate.selector); |
| 4346 | } catch { |
| 4347 | el = null; |
| 4348 | } |
| 4349 | if (!el) continue; |
| 4350 | lazyVisualContrastPending.set(el, candidate); |
| 4351 | lazyVisualContrastObserver.observe(el); |
| 4352 | } |
| 4353 | } |
| 4354 | |
| 4355 | async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { |
| 4356 | if (!shouldRunVisualContrast(options)) { |
| 4357 | lastVisualContrastAnalyses = []; |
| 4358 | disconnectLazyVisualContrastObserver(); |
| 4359 | return []; |
| 4360 | } |
| 4361 | const resolvedOptions = visualContrastOptions(options); |
| 4362 | const analyses = await analyzeVisualContrast(resolvedOptions); |
| 4363 | if (runtime.generation && runtime.generation !== scanGeneration) return analyses; |
| 4364 | lastVisualContrastAnalyses = analyses; |
| 4365 | for (const result of analyses) { |
| 4366 | addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); |
| 4367 | } |
| 4368 | if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); |
| 4369 | return analyses; |
| 4370 | } |
| 4371 | |
| 4372 | async function collectBrowserFindingsAsync(options = {}, runtime = {}) { |
| 4373 | const collected = collectBrowserFindings(); |
| 4374 | await addVisualContrastFindings(collected.groupMap, options, runtime); |
| 4375 | return { |
| 4376 | ...collected, |
| 4377 | allFindings: browserFindingsFromMap(collected.groupMap), |
| 4378 | visualContrastAnalyses: lastVisualContrastAnalyses, |
| 4379 | }; |
| 4380 | } |
| 4381 | |
| 4382 | function clearOverlays() { |
| 4383 | scanGeneration += 1; |
| 4384 | disconnectLazyVisualContrastObserver(); |
| 4385 | for (const o of [...overlays]) detachOverlay(o); |
| 4386 | overlays.length = 0; |
| 4387 | visibilityObserver.disconnect(); |
| 4388 | overlayIndex = 0; |
| 4389 | } |
| 4390 | |
| 4391 | function renderBrowserFindings(collected, options = {}) { |
| 4392 | const { allFindings, pageLevelFindings } = collected; |
| 4393 | |
| 4394 | for (const { el, findings } of allFindings) { |
| 4395 | if (el === document.body || el === document.documentElement) continue; |
| 4396 | highlight(el, findings); |
| 4397 | } |
| 4398 | |
| 4399 | if (pageLevelFindings.length > 0) { |
| 4400 | showPageBanner(pageLevelFindings); |
| 4401 | } |
| 4402 | |
| 4403 | if (!EXTENSION_MODE) printSummary(allFindings); |
| 4404 | |
| 4405 | // In extension mode, post serialized results for the DevTools panel |
| 4406 | if (EXTENSION_MODE) { |
| 4407 | window.postMessage({ |
| 4408 | source: 'impeccable-results', |
| 4409 | findings: serializeFindings(allFindings), |
| 4410 | count: allFindings.length, |
| 4411 | ...scanResultMeta(options), |
| 4412 | }, '*'); |
| 4413 | } |
| 4414 | |
| 4415 | // After this scan completes, all subsequent reveals are instant (no stagger, no animation) |
| 4416 | setTimeout(() => { firstScanDone = true; }, 1000); |
| 4417 | |
| 4418 | return allFindings; |
| 4419 | } |
| 4420 | |
| 4421 | let firstScanDone = false; |
| 4422 | const scan = function(options = {}) { |
| 4423 | clearOverlays(); |
| 4424 | const generation = scanGeneration; |
| 4425 | const collected = collectBrowserFindings(); |
| 4426 | const allFindings = renderBrowserFindings(collected, options); |
| 4427 | if (shouldRunVisualContrast(options)) { |
| 4428 | addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) |
| 4429 | .then(() => { |
| 4430 | if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options); |
| 4431 | }) |
| 4432 | .catch(err => { |
| 4433 | reportVisualContrastError(err); |
| 4434 | }); |
| 4435 | } |
| 4436 | return allFindings; |
| 4437 | }; |
| 4438 | |
| 4439 | const scanAsync = async function(options = {}) { |
| 4440 | clearOverlays(); |
| 4441 | const generation = scanGeneration; |
| 4442 | if (shouldRunVisualContrast(options)) { |
| 4443 | const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); |
| 4444 | if (generation !== scanGeneration) return []; |
| 4445 | return renderBrowserFindings(collected, options); |
| 4446 | } |
| 4447 | lastVisualContrastAnalyses = []; |
| 4448 | return renderBrowserFindings(collectBrowserFindings(), options); |
| 4449 | }; |
| 4450 | |
| 4451 | const detect = function(options = {}) { |
| 4452 | lastVisualContrastAnalyses = []; |
| 4453 | const { allFindings } = collectBrowserFindings(); |
| 4454 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 4455 | }; |
| 4456 | |
| 4457 | const detectAsync = async function(options = {}) { |
| 4458 | if (shouldRunVisualContrast(options)) { |
| 4459 | const { allFindings } = await collectBrowserFindingsAsync(options); |
| 4460 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 4461 | } |
| 4462 | lastVisualContrastAnalyses = []; |
| 4463 | const { allFindings } = collectBrowserFindings(); |
| 4464 | return options.serialize === false ? allFindings : serializeFindings(allFindings); |
| 4465 | }; |
| 4466 | |
| 4467 | if (EXTENSION_MODE) { |
| 4468 | // Extension mode: listen for commands, don't auto-scan |
| 4469 | window.addEventListener('message', (e) => { |
| 4470 | if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; |
| 4471 | if (e.data.action === 'scan') { |
| 4472 | if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; |
| 4473 | try { |
| 4474 | scan(e.data.config || {}); |
| 4475 | } catch (err) { |
| 4476 | postExtensionError(err); |
| 4477 | } |
| 4478 | } |
| 4479 | if (e.data.action === 'toggle-overlays') { |
| 4480 | const visible = !document.body.classList.contains('impeccable-hidden'); |
| 4481 | document.body.classList.toggle('impeccable-hidden', visible); |
| 4482 | window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); |
| 4483 | } |
| 4484 | if (e.data.action === 'remove') { |
| 4485 | clearOverlays(); |
| 4486 | styleEl.remove(); |
| 4487 | if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } |
| 4488 | document.body.classList.remove('impeccable-hidden'); |
| 4489 | } |
| 4490 | if (e.data.action === 'highlight') { |
| 4491 | try { |
| 4492 | const target = e.data.selector ? document.querySelector(e.data.selector) : null; |
| 4493 | if (target) { |
| 4494 | // Scroll first so positionOverlay reads the post-scroll rect |
| 4495 | if (!isInViewport(target) && target.scrollIntoView) { |
| 4496 | target.scrollIntoView({ behavior: 'instant', block: 'center' }); |
| 4497 | } |
| 4498 | for (const o of overlays) { |
| 4499 | if (o.classList.contains('impeccable-banner')) continue; |
| 4500 | const isMatch = o._targetEl === target; |
| 4501 | o.classList.toggle('impeccable-spotlight', isMatch); |
| 4502 | o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); |
| 4503 | if (isMatch) { |
| 4504 | // Force the matching overlay visible immediately, don't wait for IntersectionObserver |
| 4505 | o.style.display = ''; |
| 4506 | o.style.animation = 'none'; |
| 4507 | o.classList.add('impeccable-visible'); |
| 4508 | o._revealed = true; |
| 4509 | positionOverlay(o); |
| 4510 | } |
| 4511 | } |
| 4512 | showSpotlight(target); |
| 4513 | } |
| 4514 | } catch { /* invalid selector */ } |
| 4515 | } |
| 4516 | if (e.data.action === 'unhighlight') { |
| 4517 | hideSpotlight(); |
| 4518 | for (const o of overlays) { |
| 4519 | o.classList.remove('impeccable-spotlight'); |
| 4520 | o.classList.remove('impeccable-spotlight-dimmed'); |
| 4521 | } |
| 4522 | } |
| 4523 | }); |
| 4524 | window.postMessage({ source: 'impeccable-ready' }, '*'); |
| 4525 | } else { |
| 4526 | if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { |
| 4527 | const runAutoScan = () => { |
| 4528 | try { |
| 4529 | scan(); |
| 4530 | } catch (err) { |
| 4531 | console.warn('[impeccable] scan failed', err); |
| 4532 | } |
| 4533 | }; |
| 4534 | if (document.readyState === 'loading') { |
| 4535 | document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); |
| 4536 | } else { |
| 4537 | setTimeout(runAutoScan, 100); |
| 4538 | } |
| 4539 | } |
| 4540 | } |
| 4541 | |
| 4542 | window.impeccableDetect = detect; |
| 4543 | window.impeccableDetectAsync = detectAsync; |
| 4544 | window.impeccableScan = scan; |
| 4545 | window.impeccableScanAsync = scanAsync; |
| 4546 | window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; |
| 4547 | window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; |
| 4548 | window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); |
| 4549 | } |
| 4550 | |
| 4551 | })(); |
| 4552 |