1import {
2 BORDER_SAFE_TAGS,
3 GENERIC_FONTS,
4 KNOWN_SERIF_FONTS,
5 OVERUSED_FONTS,
6 SAFE_TAGS,
7 WCAG_LARGE_BOLD_TEXT_PX,
8 WCAG_LARGE_TEXT_PX,
9 isBrandFontOnOwnDomain,
10} from '../shared/constants.mjs';
11import {
12 colorToHex,
13 contrastRatio,
14 getHue,
15 hasChroma,
16 isNeutralColor,
17 parseGradientColors,
18 parseRgb,
19 relativeLuminance,
20} from '../shared/color.mjs';
21
22const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
23
24// ─── Section 3: Pure Detection ──────────────────────────────────────────────
25
26function checkBorders(tag, widths, colors, radius) {
27 if (BORDER_SAFE_TAGS.has(tag)) return [];
28 const findings = [];
29 const sides = ['Top', 'Right', 'Bottom', 'Left'];
30
31 for (const side of sides) {
32 const w = widths[side];
33 if (w < 1 || isNeutralColor(colors[side])) continue;
34
35 const otherSides = sides.filter(s => s !== side);
36 const maxOther = Math.max(...otherSides.map(s => widths[s]));
37 if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
38
39 const sn = side.toLowerCase();
40 const isSide = side === 'Left' || side === 'Right';
41
42 if (isSide) {
43 if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
44 else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
45 } else {
46 if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
47 }
48 }
49
50 return findings;
51}
52
53// Returns true if the given text is composed entirely of emoji characters
54// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
55// regardless of CSS `color`, so contrast checks against the element's text
56// color are meaningless for these nodes.
57const 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;
58const 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;
59function isEmojiOnlyText(text) {
60 if (!text) return false;
61 if (!EMOJI_CHAR_RE.test(text)) return false;
62 return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
63}
64
65function checkColors(opts) {
66 const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
67 if (SAFE_TAGS.has(tag)) {
68 // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
69 // exists to suppress contrast noise on inline links and unstyled controls,
70 // where the element has no own background and the contrast against the
71 // ancestor surface is already the intended visual. When the element has
72 // its own opaque background and direct text, it is a styled button — and
73 // contrast on its own surface is a real, frequent bug worth flagging.
74 const isStyledButton = (tag === 'a' || tag === 'button')
75 && hasDirectText
76 && bgColor && bgColor.a > 0.5;
77 if (!isStyledButton) return [];
78 }
79 const findings = [];
80
81 // Pure black background (only solid or near-solid, not semi-transparent overlays)
82 if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
83 findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
84 }
85
86 if (hasDirectText && textColor && !isEmojiOnly) {
87 // Run background-dependent checks against either a solid bg or, if the
88 // ancestor is a gradient, against every gradient stop (use the worst case).
89 const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
90 if (bgs) {
91 // Gray on colored background — flag if every stop is chromatic
92 const textLum = relativeLuminance(textColor);
93 const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
94 if (isGray && bgs.every(b => hasChroma(b, 40))) {
95 const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
96 findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
97 }
98
99 // Low contrast (WCAG AA) — worst case across all bg stops
100 const ratios = bgs.map(b => contrastRatio(textColor, b));
101 let worstIdx = 0;
102 for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
103 const ratio = ratios[worstIdx];
104 const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
105 const threshold = isLargeText ? 3.0 : 4.5;
106 if (ratio < threshold) {
107 // Skip the false-positive class where text has alpha < 1 AND we
108 // couldn't find an opaque ancestor (effectiveBg is null, we're
109 // comparing against gradient-stop fallback). In jsdom mode the
110 // detector can't resolve `var(--X)` color tokens, so a dark
111 // section sitting between the text and the body's decorative
112 // gradient is invisible to us — we end up measuring contrast
113 // against the body's paper-grain noise instead of the real
114 // local bg. Real low-contrast bugs use alpha=1 and have a
115 // resolvable opaque ancestor; semi-transparent Tailwind tokens
116 // like `text-paper/60` on `bg-ink` sections are the FP pattern.
117 const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
118 if (!isAlphaFallbackFP) {
119 findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
120 }
121 }
122 }
123
124 // AI palette: purple/violet on headings
125 if (hasChroma(textColor, 50)) {
126 const hue = getHue(textColor);
127 if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
128 findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
129 }
130 }
131 }
132
133 // Gradient text
134 if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
135 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
136 }
137
138 // Tailwind class checks
139 if (classList) {
140 const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
141 if (/\bbg-black\b(?!\/)/.test(classStr)) {
142 findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
143 }
144
145 const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
146 const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
147 if (grayMatch && colorBgMatch) {
148 findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
149 }
150
151 if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
152 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
153 }
154
155 const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
156 if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
157 findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
158 }
159
160 if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
161 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
162 }
163 }
164
165 return findings;
166}
167
168function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
169 if (!hasShadow && !hasBorder) return false;
170 return hasRadius || hasBg;
171}
172
173const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
174
175// Pure check: given a heading and metrics about its previousElementSibling,
176// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
177//
178// Triggers when ALL of the following hold for the sibling:
179// • size 32–128px on both axes (not too small, not a hero image)
180// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
181// • has a non-transparent background-color, background-image, OR a visible border
182// (covers solid colors, white-with-border, gradients — anything that visually
183// defines a tile)
184// • border-radius < width/2 (excludes round avatars; rounded squares pass)
185// • contains an <svg> or icon-class <i> element that's smaller than the tile
186// • the tile sits above the heading (its bottom is above the heading's top)
187function checkIconTile(opts) {
188 const { headingTag, headingText, headingTop,
189 siblingTag, siblingWidth, siblingHeight, siblingBottom,
190 siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
191 hasIconChild, iconChildWidth } = opts;
192 if (!HEADING_TAGS.has(headingTag)) return [];
193 if (!siblingTag) return [];
194 // Don't recurse into nested headings (e.g. h2 above h3 in a section header)
195 if (HEADING_TAGS.has(siblingTag)) return [];
196
197 // Size window: 32–128px on each axis
198 if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
199 if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
200
201 // Squarish aspect ratio
202 const ratio = siblingWidth / siblingHeight;
203 if (ratio < 0.7 || ratio > 1.4) return [];
204
205 // Must have something that visually defines the tile
206 const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
207 || (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
208 const borderVisible = siblingBorderWidth > 0;
209 if (!bgVisible && !borderVisible) return [];
210
211 // Exclude circles (avatars). Rounded squares pass.
212 if (siblingBorderRadius >= siblingWidth / 2) return [];
213
214 // Must contain an icon element smaller than the tile
215 if (!hasIconChild) return [];
216 if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
217
218 // Vertical stacking: tile must end above where the heading starts.
219 // (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
220 if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
221
222 const text = (headingText || '').trim().slice(0, 60);
223 return [{
224 id: 'icon-tile-stack',
225 snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
226 }];
227}
228
229// Resolve the primary (non-generic) face from a font-family string and return
230// whether the resolved primary is serif. Two paths:
231// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
232// 2. Primary face is unknown but the stack ends in the generic `serif`
233// token → treat as serif. Authors who declare `font-family: 'X', serif`
234// almost always have a serif primary; a sans declared with a serif
235// fallback is a code smell, not the common case.
236// Returns { primary, isSerif } so the snippet can name the face.
237function resolveSerif(fontFamily) {
238 if (!fontFamily) return { primary: null, isSerif: false };
239 const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
240 const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
241 if (!primary) return { primary: null, isSerif: false };
242 if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
243 if (tokens.includes('serif')) return { primary, isSerif: true };
244 return { primary, isSerif: false };
245}
246
247function checkItalicSerif(opts) {
248 const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
249 if (fontStyle !== 'italic') return [];
250 // Anchor the rule on hero-scale text. h1 is the canonical hero element;
251 // h2 ≥ 48px catches the cases where the design demotes the visual hero
252 // to an h2 but keeps the size.
253 if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
254 if (fontSize < 48) return [];
255 const { primary, isSerif } = resolveSerif(fontFamily);
256 if (!isSerif) return [];
257
258 const text = (headingText || '').trim().slice(0, 60);
259 return [{
260 id: 'italic-serif-display',
261 snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
262 }];
263}
264
265// Color saturation check. Returns true when the color has visible
266// chroma — i.e., it's an "accent color" rather than near-neutral.
267// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
268// expected to be pre-resolved by the caller.
269function isAccentColor(cssColor) {
270 if (!cssColor) return false;
271 const s = String(cssColor).trim();
272 // rgb / rgba — direct channel-distance check.
273 const rgbM = /rgba?\(\s*(\d+)\s*,?\s+|\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s.replace(/rgba?\(\s*/, 'rgb(').replace(/,/g, ', '));
274 const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
275 if (rgbStrict) {
276 const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
277 return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
278 }
279 // #hex — 3, 4, 6, or 8 digit.
280 const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
281 if (hexM) {
282 let h = hexM[1];
283 if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
284 else h = h.slice(0, 6);
285 if (h.length === 6) {
286 const r = parseInt(h.slice(0, 2), 16);
287 const g = parseInt(h.slice(2, 4), 16);
288 const b = parseInt(h.slice(4, 6), 16);
289 return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
290 }
291 }
292 // oklch(L C H) — chroma C is what matters. Typical neutral grays
293 // have C < 0.02; visible accents are 0.05+. CSS minification can
294 // collapse spaces between L% and C ("oklch(43%.15 34)"), so we
295 // extract all numbers and take the second rather than matching a
296 // strict L-then-whitespace-then-C pattern.
297 if (/^oklch\(/i.test(s)) {
298 const nums = s.match(/\d*\.\d+|\d+/g);
299 if (nums && nums.length >= 2) {
300 const c = parseFloat(nums[1]);
301 return !Number.isNaN(c) && c >= 0.05;
302 }
303 }
304 // hsl(H, S%, L%) — saturation > 20% reads as accent.
305 const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
306 if (hslM) {
307 const sat = parseFloat(hslM[1]);
308 return !Number.isNaN(sat) && sat >= 20;
309 }
310 return false;
311}
312
313// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
314// previousElementSibling, and gate on EITHER the classic tracked-
315// uppercase eyebrow OR the modern accent-colored bold eyebrow.
316function checkHeroEyebrow(opts) {
317 const {
318 headingTag, headingText, headingFontSize,
319 siblingTag, siblingText, siblingTextTransform,
320 siblingFontSize, siblingLetterSpacing,
321 siblingFontWeight, siblingColor,
322 } = opts;
323 if (headingTag !== 'h1') return [];
324 // We previously gated on headingFontSize >= 48 to anchor "hero scale".
325 // But modern hero h1s use clamp() / vw / var(--text-*), none of which
326 // jsdom can resolve — the computed value comes back as "2em" or
327 // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
328 // on virtually every Tailwind v4 / framework build. The other gates
329 // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
330 // tracked-caps) are tight enough to avoid false positives on non-
331 // hero h1s — a tiny tan label directly above any h1 is the
332 // antipattern regardless of how big the h1 ends up.
333 if (!siblingTag) return [];
334 // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
335 // headings) — never an eyebrow.
336 if (HEADING_TAGS.has(siblingTag)) return [];
337
338 const text = (siblingText || '').trim();
339 if (text.length < 2 || text.length > 60) return [];
340 if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
341
342 // Branch A: classic tracked-uppercase eyebrow.
343 const isUppercased = siblingTextTransform === 'uppercase'
344 || (/[A-Z]/.test(text) && !/[a-z]/.test(text));
345 const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
346
347 // Branch B: modern accent-bold eyebrow — sentence case, low
348 // tracking, but bold + accent-colored. The style choices changed;
349 // the pattern is the same kicker-above-headline anti-pattern.
350 const weight = Number(siblingFontWeight) || 400;
351 const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
352
353 if (!isClassicTracked && !isAccentBold) return [];
354
355 const headingTextSnippet = (headingText || '').trim().slice(0, 60);
356 const eyebrowSnippet = text.slice(0, 40);
357 const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
358 return [{
359 id: 'hero-eyebrow-chip',
360 snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
361 }];
362}
363
364function checkRepeatedSectionKickers(opts) {
365 const { candidates, minCount = 3 } = opts;
366 if (!Array.isArray(candidates) || candidates.length < minCount) return [];
367 return candidates.map(candidate => ({
368 id: 'repeated-section-kickers',
369 snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
370 }));
371}
372
373const LAYOUT_TRANSITION_PROPS = new Set([
374 'width', 'height', 'padding', 'margin',
375 'max-height', 'max-width', 'min-height', 'min-width',
376 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
377 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
378]);
379
380function checkMotion(opts) {
381 const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
382 if (SAFE_TAGS.has(tag)) return [];
383 const findings = [];
384
385 // --- Bounce/elastic easing ---
386 if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
387 findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
388 }
389 if (classList && /\banimate-bounce\b/.test(classList)) {
390 findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
391 }
392
393 // Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
394 if (timingFunctions) {
395 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
396 let m;
397 while ((m = bezierRe.exec(timingFunctions)) !== null) {
398 const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
399 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
400 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
401 break;
402 }
403 }
404 }
405
406 // --- Layout property transition ---
407 if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
408 const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
409 const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
410 if (layoutFound.length > 0) {
411 findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
412 }
413 }
414
415 return findings;
416}
417
418function checkGlow(opts) {
419 const { boxShadow, effectiveBg } = opts;
420 if (!boxShadow || boxShadow === 'none') return [];
421 if (!effectiveBg) return [];
422
423 // Only flag on dark backgrounds (luminance < 0.1)
424 const bgLum = relativeLuminance(effectiveBg);
425 if (bgLum >= 0.1) return [];
426
427 // Split multiple shadows (commas not inside parentheses)
428 const parts = boxShadow.split(/,(?![^(]*\))/);
429 for (const shadow of parts) {
430 const colorMatch = shadow.match(/rgba?\([^)]+\)/);
431 if (!colorMatch) continue;
432 const color = parseRgb(colorMatch[0]);
433 if (!color || !hasChroma(color, 30)) continue;
434
435 // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
436 const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
437 const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
438 const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
439 .map(m => parseFloat(m[1]));
440
441 // Third value is blur (offset-x, offset-y, blur, [spread])
442 if (pxVals.length >= 3 && pxVals[2] > 4) {
443 return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
444 }
445 }
446
447 return [];
448}
449
450/**
451 * Regex-on-HTML checks shared between browser and Node page-level detection.
452 * These don't need DOM access, just the raw HTML string.
453 */
454function checkHtmlPatterns(html) {
455 const findings = [];
456
457 // --- Color ---
458
459 // Pure black background
460 const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
461 if (pureBlackBgRe.test(html)) {
462 findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
463 }
464
465 // AI color palette: purple/violet
466 const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
467 if (purpleHexRe.test(html)) {
468 const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
469 if (purpleTextRe.test(html)) {
470 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
471 }
472 }
473
474 // Gradient text (background-clip: text + gradient)
475 const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
476 let gm;
477 while ((gm = gradientRe.exec(html)) !== null) {
478 const start = Math.max(0, gm.index - 200);
479 const context = html.substring(start, gm.index + gm[0].length + 200);
480 if (/gradient/i.test(context)) {
481 findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
482 break;
483 }
484 }
485 if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
486 findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
487 }
488
489 // --- Layout ---
490
491 // Monotonous spacing
492 const spacingValues = [];
493 const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
494 let sm;
495 while ((sm = spacingRe.exec(html)) !== null) {
496 const v = parseInt(sm[1], 10);
497 if (v > 0 && v < 200) spacingValues.push(v);
498 }
499 const gapRe = /gap\s*:\s*(\d+)px/gi;
500 while ((sm = gapRe.exec(html)) !== null) {
501 spacingValues.push(parseInt(sm[1], 10));
502 }
503 const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
504 while ((sm = twSpaceRe.exec(html)) !== null) {
505 spacingValues.push(parseInt(sm[1], 10) * 4);
506 }
507 const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
508 while ((sm = remSpacingRe.exec(html)) !== null) {
509 const v = Math.round(parseFloat(sm[1]) * 16);
510 if (v > 0 && v < 200) spacingValues.push(v);
511 }
512 const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
513 if (roundedSpacing.length >= 10) {
514 const counts = {};
515 for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
516 const maxCount = Math.max(...Object.values(counts));
517 const dominantPct = maxCount / roundedSpacing.length;
518 const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
519 if (dominantPct > 0.6 && unique.length <= 3) {
520 const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
521 findings.push({
522 id: 'monotonous-spacing',
523 snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
524 });
525 }
526 }
527
528 // --- Motion ---
529
530 // Bounce/elastic animation names
531 const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
532 if (bounceRe.test(html)) {
533 findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
534 }
535
536 // Overshoot cubic-bezier
537 const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
538 let bm;
539 while ((bm = bezierRe.exec(html)) !== null) {
540 const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
541 if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
542 findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
543 break;
544 }
545 }
546
547 // Layout property transitions
548 const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
549 let tm;
550 while ((tm = transRe.exec(html)) !== null) {
551 const val = tm[1].toLowerCase();
552 if (/\ball\b/.test(val)) continue;
553 const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
554 if (found) {
555 findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
556 break;
557 }
558 }
559
560 // --- Dark glow ---
561
562 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;
563 const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
564 if (darkBgRe.test(html) || twDarkBg.test(html)) {
565 const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
566 let shm;
567 while ((shm = shadowRe.exec(html)) !== null) {
568 const val = shm[1];
569 const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
570 if (!colorMatch) continue;
571 const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
572 if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
573 const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
574 if (pxVals.length >= 3 && pxVals[2] > 4) {
575 findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
576 break;
577 }
578 }
579 }
580
581 return findings;
582}
583
584// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
585
586// Read the element's own background color, computed-style first, with a
587// jsdom-friendly fallback that parses the inline `background:` shorthand
588// from the raw style attribute. jsdom (~v29) does not decompose the
589// shorthand into `backgroundColor`, so without this fallback the CLI silently
590// returns null for any element styled via `background: rgb(...)` or
591// `background: #abc`. Real browsers always decompose, so the fallback is
592// a no-op there.
593function readOwnBackgroundColor(el, computedStyle) {
594 const bg = parseRgb(computedStyle.backgroundColor);
595 if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
596 const rawStyle = el.getAttribute?.('style') || '';
597 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
598 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
599 if (!inlineBg) return bg;
600 if (/gradient/i.test(inlineBg) || /url\s*\(/i.test(inlineBg)) return bg;
601 const fromRgb = parseRgb(inlineBg);
602 if (fromRgb) return fromRgb;
603 const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
604 if (hexMatch) {
605 const h = hexMatch[1];
606 if (h.length === 6) {
607 return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
608 }
609 return { r: parseInt(h[0] + h[0], 16), g: parseInt(h[1] + h[1], 16), b: parseInt(h[2] + h[2], 16), a: 1 };
610 }
611 return bg;
612}
613
614function resolveBackground(el, win, customPropMap) {
615 let current = el;
616 while (current && current.nodeType === 1) {
617 const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
618 const bgImage = style.backgroundImage || '';
619 const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
620
621 // Try the solid bg-color FIRST. If the element has both a solid color
622 // and a gradient/url overlay (a common pattern: `background: var(--paper)
623 // radial-gradient(...)` for paper-grain texture), the solid color is the
624 // dominant visible surface for contrast purposes; the overlay is
625 // decorative. The old behavior bailed on any gradient ancestor, which
626 // caused massive false-positive contrast findings on grain-textured
627 // body backgrounds.
628 let bg = parseRgb(style.backgroundColor);
629 if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
630 // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
631 // through customPropMap so Tailwind v4 color tokens become RGB.
632 if (customPropMap) {
633 bg = parseColorResolved(style.backgroundColor, customPropMap);
634 }
635 if (!bg || bg.a < 0.1) {
636 // Inline-style fallback. jsdom doesn't decompose background
637 // shorthand, so colors set via inline style are otherwise invisible.
638 const rawStyle = current.getAttribute?.('style') || '';
639 const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
640 const inlineBg = bgMatch ? bgMatch[1].trim() : '';
641 if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
642 bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
643 }
644 }
645 }
646
647 if (bg && bg.a > 0.1) {
648 if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
649 }
650 // No solid bg-color at this level. If THIS level has a gradient/url
651 // with no underlying solid color we can read:
652 // • on body/html: assume white. Body-level gradients are almost
653 // always decorative texture (paper grain, noise) on top of a
654 // solid bg-color the page set via `background: var(--paper)`
655 // shorthand — which jsdom can't decompose into bg-color. The
656 // downstream gradient-stops fallback path produces catastrophic
657 // false positives in this case (gradient noise stops have
658 // accidental browns/blacks that look like card backgrounds).
659 // • on other elements: bail to null and let the caller fall back
660 // to gradient stops (gradient buttons / hero sections are real
661 // bgs worth checking against).
662 if (hasGradientOrUrl) {
663 if (current.tagName === 'BODY' || current.tagName === 'HTML') {
664 return { r: 255, g: 255, b: 255, a: 1 };
665 }
666 return null;
667 }
668 current = current.parentElement;
669 }
670 return { r: 255, g: 255, b: 255 };
671}
672
673// Walk parents looking for a gradient background and return its color stops.
674// Used as a fallback when resolveBackground() returns null because the
675// effective background is a gradient (no single solid color to compare against).
676function resolveGradientStops(el, win) {
677 let current = el;
678 while (current && current.nodeType === 1) {
679 const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
680 const bgImage = style.backgroundImage || '';
681 if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
682 const stops = parseGradientColors(bgImage);
683 if (stops.length > 0) return stops;
684 }
685 if (!DETECTOR_IS_BROWSER) {
686 // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
687 const rawStyle = current.getAttribute?.('style') || '';
688 const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
689 if (bgMatch && /gradient/i.test(bgMatch[1])) {
690 const stops = parseGradientColors(bgMatch[1]);
691 if (stops.length > 0) return stops;
692 }
693 }
694 current = current.parentElement;
695 }
696 return null;
697}
698
699// Parse a single CSS length token to pixels. Accepts "12px", "50%", a
700// shorthand like "12px 4px" (uses the first value), or empty / null.
701// Returns the pixel value, or null when the input is unparseable.
702// Percentages convert against `widthPx` when one is supplied. Without a
703// usable width (jsdom returns "auto" for many real-world elements,
704// which parseFloat collapses to 0), fall back to the raw percentage
705// number so callers gating on `> 0` (border-accent-on-rounded,
706// isCardLike's hasRadius) still see a positive value, matching the
707// original parseFloat("50%") === 50 behavior.
708function parseRadiusToPx(value, widthPx) {
709 if (!value || typeof value !== 'string') return null;
710 const trimmed = value.trim();
711 if (!trimmed) return null;
712 const first = trimmed.split(/\s+/)[0];
713 const num = parseFloat(first);
714 if (Number.isNaN(num)) return null;
715 if (/%$/.test(first)) {
716 if (widthPx && widthPx > 0) return (num / 100) * widthPx;
717 return num;
718 }
719 return num;
720}
721
722function resolveBorderRadiusPx(el, style, widthPx, win) {
723 const fromComputed = parseRadiusToPx(style.borderRadius, widthPx);
724 if (fromComputed !== null) return fromComputed;
725 return 0;
726}
727
728// ─── Section 5: Element Adapters ────────────────────────────────────────────
729
730// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
731
732function checkElementBordersDOM(el) {
733 const tag = el.tagName.toLowerCase();
734 if (BORDER_SAFE_TAGS.has(tag)) return [];
735 const rect = el.getBoundingClientRect();
736 if (rect.width < 20 || rect.height < 20) return [];
737 const style = getComputedStyle(el);
738 const sides = ['Top', 'Right', 'Bottom', 'Left'];
739 const widths = {}, colors = {};
740 for (const s of sides) {
741 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
742 colors[s] = style[`border${s}Color`] || '';
743 }
744 return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
745}
746
747function checkElementColorsDOM(el) {
748 const tag = el.tagName.toLowerCase();
749 // No early SAFE_TAGS bail here — checkColors() does its own gating that
750 // includes the styled-button exception for <a> / <button> with their own
751 // opaque background. Bailing here would prevent that exception from firing.
752 const rect = el.getBoundingClientRect();
753 if (rect.width < 10 || rect.height < 10) return [];
754 const style = getComputedStyle(el);
755 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
756 const hasDirectText = directText.trim().length > 0;
757 const effectiveBg = resolveBackground(el);
758 return checkColors({
759 tag,
760 textColor: parseRgb(style.color),
761 bgColor: readOwnBackgroundColor(el, style),
762 effectiveBg,
763 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
764 fontSize: parseFloat(style.fontSize) || 16,
765 fontWeight: parseInt(style.fontWeight) || 400,
766 hasDirectText,
767 isEmojiOnly: isEmojiOnlyText(directText),
768 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
769 bgImage: style.backgroundImage || '',
770 classList: el.getAttribute('class') || '',
771 });
772}
773
774function checkElementIconTileDOM(el) {
775 const tag = el.tagName.toLowerCase();
776 if (!HEADING_TAGS.has(tag)) return [];
777 const sibling = el.previousElementSibling;
778 if (!sibling) return [];
779
780 const sibRect = sibling.getBoundingClientRect();
781 const headRect = el.getBoundingClientRect();
782 const sibStyle = getComputedStyle(sibling);
783
784 // The tile may either contain an <svg>/<i> icon child, OR the tile itself
785 // may contain an emoji/symbol character directly as its only text content
786 // (the "card-icon" pattern from many AI-generated demos).
787 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
788 const iconRect = iconChild?.getBoundingClientRect();
789 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
790 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
791
792 return checkIconTile({
793 headingTag: tag,
794 headingText: el.textContent || '',
795 headingTop: headRect.top,
796 siblingTag: sibling.tagName.toLowerCase(),
797 siblingWidth: sibRect.width,
798 siblingHeight: sibRect.height,
799 siblingBottom: sibRect.bottom,
800 siblingBgColor: parseRgb(sibStyle.backgroundColor),
801 siblingBgImage: sibStyle.backgroundImage || '',
802 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
803 siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
804 hasIconChild: !!iconChild || hasInlineEmojiIcon,
805 iconChildWidth: iconRect?.width || 0,
806 });
807}
808
809function checkElementItalicSerifDOM(el) {
810 const tag = el.tagName.toLowerCase();
811 if (tag !== 'h1' && tag !== 'h2') return [];
812 const style = getComputedStyle(el);
813 return checkItalicSerif({
814 tag,
815 fontStyle: style.fontStyle || '',
816 fontFamily: style.fontFamily || '',
817 fontSize: parseFloat(style.fontSize) || 0,
818 headingText: el.textContent || '',
819 });
820}
821
822function checkElementHeroEyebrowDOM(el) {
823 const tag = el.tagName.toLowerCase();
824 if (tag !== 'h1') return [];
825 const sibling = el.previousElementSibling;
826 if (!sibling) return [];
827 const headStyle = getComputedStyle(el);
828 const sibStyle = getComputedStyle(sibling);
829 return checkHeroEyebrow({
830 headingTag: tag,
831 headingText: el.textContent || '',
832 headingFontSize: parseFloat(headStyle.fontSize) || 0,
833 siblingTag: sibling.tagName.toLowerCase(),
834 siblingText: sibling.textContent || '',
835 siblingTextTransform: sibStyle.textTransform || '',
836 siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
837 siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
838 siblingFontWeight: sibStyle.fontWeight || '',
839 siblingColor: sibStyle.color || '',
840 });
841}
842
843// Build a map of CSS custom properties declared on :root / :host / html.
844// Used to resolve var(--X) refs that jsdom returns verbatim in
845// getComputedStyle. Tailwind v4 routes every utility class through
846// CSS vars (font-weight: var(--font-weight-bold), font-size:
847// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
848// resolution every style-based check silently fails on Tailwind v4
849// builds — the values come back as literal "var(--font-weight-bold)"
850// strings and parseFloat returns NaN.
851function buildCustomPropMap(document) {
852 const map = new Map();
853 let sheets;
854 try { sheets = Array.from(document.styleSheets || []); }
855 catch { return map; }
856 for (const sheet of sheets) {
857 let rules;
858 try { rules = Array.from(sheet.cssRules || []); }
859 catch { continue; }
860 for (const rule of rules) {
861 // Style rules only (type 1). Walk @media / @supports if present.
862 if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
863 try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
864 continue;
865 }
866 if (rule.type !== 1 /* STYLE_RULE */) continue;
867 const sel = rule.selectorText || '';
868 if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
869 const style = rule.style;
870 if (!style) continue;
871 for (let i = 0; i < style.length; i++) {
872 const prop = style[i];
873 if (!prop || !prop.startsWith('--')) continue;
874 const val = style.getPropertyValue(prop).trim();
875 if (val) map.set(prop, val);
876 }
877 }
878 }
879 return map;
880}
881
882// Resolve var(--X[, fallback]) refs in a computed-style value string.
883// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
884// the original string when no refs are present or the chain doesn't
885// resolve. Safe to call on already-resolved values.
886function resolveVarRefs(raw, customPropMap, depth = 0) {
887 if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
888 if (depth > 8) return raw;
889 return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
890 const v = customPropMap.get(name);
891 if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
892 return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
893 });
894}
895
896// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
897// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
898// Needed because jsdom doesn't compute oklch() values — getComputedStyle
899// returns the literal "oklch(...)" string. Without this, the entire
900// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
901// detector's contrast / color checks.
902function oklchToRgb(L, C, H) {
903 const hRad = (H * Math.PI) / 180;
904 const a = C * Math.cos(hRad);
905 const b = C * Math.sin(hRad);
906 const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
907 const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
908 const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
909 const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
910 const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
911 const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
912 const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
913 const enc = (x) => {
914 const c = Math.max(0, Math.min(1, x));
915 return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
916 };
917 return {
918 r: Math.round(enc(rLin) * 255),
919 g: Math.round(enc(gLin) * 255),
920 b: Math.round(enc(bLin) * 255),
921 a: 1,
922 };
923}
924
925// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
926// Use this when the input might be any CSS color form; use plain parseRgb
927// when you only expect computed rgb() values from real browsers.
928function parseAnyColor(s) {
929 if (!s || typeof s !== 'string') return null;
930 const str = s.trim();
931 if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
932 let m;
933 m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
934 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 };
935 m = str.match(/^#([0-9a-f]{3,8})$/i);
936 if (m) {
937 const h = m[1];
938 if (h.length === 3 || h.length === 4) {
939 return {
940 r: parseInt(h[0] + h[0], 16),
941 g: parseInt(h[1] + h[1], 16),
942 b: parseInt(h[2] + h[2], 16),
943 a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
944 };
945 }
946 if (h.length === 6 || h.length === 8) {
947 return {
948 r: parseInt(h.slice(0, 2), 16),
949 g: parseInt(h.slice(2, 4), 16),
950 b: parseInt(h.slice(4, 6), 16),
951 a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
952 };
953 }
954 }
955 // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
956 // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
957 // Match L (with optional %), then C and H separated permissively.
958 m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
959 if (m) {
960 const Lnum = parseFloat(m[1]);
961 const L = m[2] === '%' ? Lnum / 100 : Lnum;
962 return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
963 }
964 return null;
965}
966
967// Resolve var() refs in a color string (via customPropMap), then parse.
968// Returns null on any failure. Used in jsdom-mode paths where
969// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
970function parseColorResolved(str, customPropMap) {
971 if (!str) return null;
972 const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
973 return parseAnyColor(resolved);
974}
975
976const REPEATED_KICKER_SKIP_SELECTOR = [
977 'nav',
978 'form',
979 'table',
980 'thead',
981 'tbody',
982 'tfoot',
983 'figure',
984 'figcaption',
985 'ol',
986 'ul',
987 'li',
988 '[role="navigation"]',
989 '[aria-label*="breadcrumb" i]',
990 '[class*="breadcrumb" i]',
991 '[data-impeccable-allow-kickers]',
992].join(',');
993
994function cleanInlineText(el) {
995 return [...el.childNodes]
996 .filter(n => n.nodeType === 3)
997 .map(n => n.textContent)
998 .join(' ')
999 .replace(/\s+/g, ' ')
1000 .trim();
1001}
1002
1003function isRepeatedKickerCandidate(opts) {
1004 const {
1005 headingTag,
1006 headingText,
1007 headingFontSize,
1008 kickerTag,
1009 kickerText,
1010 kickerTextTransform,
1011 kickerFontSize,
1012 kickerLetterSpacing,
1013 } = opts;
1014 if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
1015 if (!headingText || headingText.length < 3) return false;
1016 if (!(headingFontSize >= 20)) return false;
1017 if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
1018 if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
1019 if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
1020 if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
1021
1022 const isUppercased = kickerTextTransform === 'uppercase'
1023 || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
1024 if (!isUppercased) return false;
1025 if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
1026 const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
1027 if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
1028 return true;
1029}
1030
1031function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
1032 const candidates = [];
1033 for (const heading of doc.querySelectorAll('h2, h3, h4')) {
1034 if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1035 const kicker = heading.previousElementSibling;
1036 if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1037
1038 const headingStyle = getStyle(heading);
1039 const kickerStyle = getStyle(kicker);
1040 const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
1041 const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
1042 const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
1043 const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
1044 const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
1045
1046 if (!isRepeatedKickerCandidate({
1047 headingTag: heading.tagName.toLowerCase(),
1048 headingText,
1049 headingFontSize,
1050 kickerTag: kicker.tagName.toLowerCase(),
1051 kickerText,
1052 kickerTextTransform: kickerStyle.textTransform || '',
1053 kickerFontSize,
1054 kickerLetterSpacing,
1055 })) {
1056 continue;
1057 }
1058
1059 candidates.push({
1060 headingTag: heading.tagName.toLowerCase(),
1061 headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
1062 kickerText: kickerText.slice(0, 40),
1063 });
1064 }
1065 return candidates;
1066}
1067
1068function checkRepeatedSectionKickersDOM() {
1069 const candidates = collectRepeatedSectionKickerCandidates(
1070 document,
1071 (el) => getComputedStyle(el),
1072 (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1073 );
1074 return checkRepeatedSectionKickers({ candidates });
1075}
1076
1077function checkElementMotionDOM(el) {
1078 const tag = el.tagName.toLowerCase();
1079 if (SAFE_TAGS.has(tag)) return [];
1080 const style = getComputedStyle(el);
1081 return checkMotion({
1082 tag,
1083 transitionProperty: style.transitionProperty || '',
1084 animationName: style.animationName || '',
1085 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1086 classList: el.getAttribute('class') || '',
1087 });
1088}
1089
1090function checkElementGlowDOM(el) {
1091 const tag = el.tagName.toLowerCase();
1092 const style = getComputedStyle(el);
1093 if (!style.boxShadow || style.boxShadow === 'none') return [];
1094 // Use parent's background — glow radiates outward, so the surrounding context matters
1095 // If resolveBackground returns null (gradient), try to infer from the gradient colors
1096 let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
1097 if (!parentBg) {
1098 // Gradient background — sample its colors to determine if it's dark
1099 let cur = el.parentElement;
1100 while (cur && cur.nodeType === 1) {
1101 const bgImage = getComputedStyle(cur).backgroundImage || '';
1102 const gradColors = parseGradientColors(bgImage);
1103 if (gradColors.length > 0) {
1104 // Average the gradient colors
1105 const avg = { r: 0, g: 0, b: 0 };
1106 for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1107 avg.r = Math.round(avg.r / gradColors.length);
1108 avg.g = Math.round(avg.g / gradColors.length);
1109 avg.b = Math.round(avg.b / gradColors.length);
1110 parentBg = avg;
1111 break;
1112 }
1113 cur = cur.parentElement;
1114 }
1115 }
1116 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
1117}
1118
1119function checkElementAIPaletteDOM(el) {
1120 const style = getComputedStyle(el);
1121 const findings = [];
1122
1123 // Check gradient backgrounds for purple/violet or cyan
1124 const bgImage = style.backgroundImage || '';
1125 const gradColors = parseGradientColors(bgImage);
1126 for (const c of gradColors) {
1127 if (hasChroma(c, 50)) {
1128 const hue = getHue(c);
1129 if (hue >= 260 && hue <= 310) {
1130 findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
1131 break;
1132 }
1133 if (hue >= 160 && hue <= 200) {
1134 findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
1135 break;
1136 }
1137 }
1138 }
1139
1140 // Check for neon text (vivid cyan/purple color on dark background)
1141 const textColor = parseRgb(style.color);
1142 if (textColor && hasChroma(textColor, 80)) {
1143 const hue = getHue(textColor);
1144 const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
1145 if (isAIPalette) {
1146 const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
1147 // Also check gradient parents
1148 let effectiveBg = parentBg;
1149 if (!effectiveBg) {
1150 let cur = el.parentElement;
1151 while (cur && cur.nodeType === 1) {
1152 const gi = getComputedStyle(cur).backgroundImage || '';
1153 const gc = parseGradientColors(gi);
1154 if (gc.length > 0) {
1155 const avg = { r: 0, g: 0, b: 0 };
1156 for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
1157 avg.r = Math.round(avg.r / gc.length);
1158 avg.g = Math.round(avg.g / gc.length);
1159 avg.b = Math.round(avg.b / gc.length);
1160 effectiveBg = avg;
1161 break;
1162 }
1163 cur = cur.parentElement;
1164 }
1165 }
1166 if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
1167 const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
1168 findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
1169 }
1170 }
1171 }
1172
1173 return findings;
1174}
1175
1176const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
1177
1178// Resolve a CSS font-size value to pixels by walking up the parent chain.
1179// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
1180// specified value verbatim — so for the Node path we walk parents ourselves.
1181function resolveFontSizePx(el, win) {
1182 const chain = []; // raw font-size strings, leaf → root
1183 let cur = el;
1184 while (cur && cur.nodeType === 1) {
1185 const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
1186 chain.push(fs || '');
1187 cur = cur.parentElement;
1188 }
1189 // Walk root → leaf, resolving each value relative to its parent context.
1190 let px = 16; // root default
1191 for (let i = chain.length - 1; i >= 0; i--) {
1192 const v = chain[i];
1193 if (!v || v === 'inherit') continue;
1194 const num = parseFloat(v);
1195 if (isNaN(num)) continue;
1196 if (v.endsWith('px')) px = num;
1197 else if (v.endsWith('rem')) px = num * 16;
1198 else if (v.endsWith('em')) px = num * px;
1199 else if (v.endsWith('%')) px = (num / 100) * px;
1200 else px = num; // unitless — already resolved
1201 }
1202 return px;
1203}
1204
1205// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
1206// known font-size context. Returns null for "normal" / unparseable values.
1207function resolveLengthPx(value, fontSizePx) {
1208 if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
1209 const num = parseFloat(value);
1210 if (isNaN(num)) return null;
1211 if (value.endsWith('px')) return num;
1212 if (value.endsWith('rem')) return num * 16;
1213 if (value.endsWith('em')) return num * fontSizePx;
1214 if (value.endsWith('%')) return (num / 100) * fontSizePx;
1215 // Unitless line-height = multiplier, return px equivalent
1216 return num * fontSizePx;
1217}
1218
1219// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1220// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1221// element rect dimensions, which jsdom can't compute — pass `rect: null` from
1222// the Node adapter to skip those.
1223//
1224// Both adapters resolve font-size, line-height and letter-spacing to pixels
1225// before calling this so the pure function only deals with numbers.
1226function checkQuality(opts) {
1227 const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0 } = opts;
1228 const findings = [];
1229 // Skip browser extension injected elements
1230 const elId = el.id || '';
1231 if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1232
1233 // --- Line length too long --- (browser-only: needs rect.width)
1234 if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
1235 const charsPerLine = rect.width / (fontSize * 0.5);
1236 if (charsPerLine > lineMax + 5) {
1237 findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
1238 }
1239 }
1240
1241 // --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
1242 // Vertical and horizontal thresholds are independent because line-height
1243 // already provides built-in vertical breathing room (the line box is taller
1244 // than the cap height), but horizontal has no equivalent. Both scale with
1245 // font-size — bigger text demands proportionally more padding.
1246 // vertical: max(4px, fontSize × 0.3)
1247 // horizontal: max(8px, fontSize × 0.5)
1248 if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
1249 const borders = {
1250 top: parseFloat(style.borderTopWidth) || 0,
1251 right: parseFloat(style.borderRightWidth) || 0,
1252 bottom: parseFloat(style.borderBottomWidth) || 0,
1253 left: parseFloat(style.borderLeftWidth) || 0,
1254 };
1255 const borderCount = Object.values(borders).filter(w => w > 0).length;
1256 const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
1257 if (borderCount >= 2 || hasBg) {
1258 const vPads = [], hPads = [];
1259 if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
1260 if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
1261 if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
1262 if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
1263
1264 const vMin = vPads.length ? Math.min(...vPads) : Infinity;
1265 const hMin = hPads.length ? Math.min(...hPads) : Infinity;
1266 const vThresh = Math.max(4, fontSize * 0.3);
1267 const hThresh = Math.max(8, fontSize * 0.5);
1268
1269 // Emit at most one finding per element — pick whichever axis is worse.
1270 if (vMin < vThresh) {
1271 findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
1272 } else if (hMin < hThresh) {
1273 findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
1274 }
1275 }
1276 }
1277
1278 // --- Body text touching viewport edge --- (browser-only: needs rect)
1279 // Catches the failure mode where the agent ships body paragraphs
1280 // with NO container providing horizontal padding — text bleeds
1281 // directly to the viewport edge. Different from cramped-padding,
1282 // which requires a colored/bordered container. Here the failure
1283 // is the absence of the container entirely.
1284 //
1285 // Gate aggressively to avoid false positives:
1286 // - <p> or <li> only (body content; not headings, not nav, not
1287 // wrappers)
1288 // - text > 40 chars (paragraph-like, not a label)
1289 // - rect.width > 50% of viewport (real body, not a pull-quote)
1290 // - rect.left < 16 OR rect.right > viewport - 16 (actually
1291 // touching the edge)
1292 // - not inside <nav> or <header> (those legitimately bleed)
1293 // - element itself has no background-color (intentional full-bleed
1294 // sections set a bg-color and provide their own internal padding)
1295 if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
1296 const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
1297 const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
1298 const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
1299 const widthRatio = rect.width / viewportWidth;
1300 const leftClose = rect.left < 16;
1301 const rightClose = rect.right > viewportWidth - 16;
1302 if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
1303 const which = leftClose && rightClose
1304 ? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
1305 : leftClose
1306 ? `left ${Math.round(rect.left)}px`
1307 : `right ${Math.round(viewportWidth - rect.right)}px`;
1308 findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
1309 }
1310 }
1311
1312 // --- Tight line height ---
1313 if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1314 if (lineHeightPx != null && fontSize > 0) {
1315 const ratio = lineHeightPx / fontSize;
1316 if (ratio > 0 && ratio < 1.3) {
1317 findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
1318 }
1319 }
1320 }
1321
1322 // --- Justified text (without hyphens) ---
1323 if (hasDirectText && style.textAlign === 'justify') {
1324 const hyphens = style.hyphens || style.webkitHyphens || '';
1325 if (hyphens !== 'auto') {
1326 findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
1327 }
1328 }
1329
1330 // --- Tiny body text ---
1331 // Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
1332 if (hasDirectText && textLen > 20 && fontSize < 12) {
1333 const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1334 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]');
1335 const isUppercase = style.textTransform === 'uppercase';
1336 if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
1337 findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1338 }
1339 }
1340
1341 // --- All-caps body text ---
1342 if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1343 if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
1344 findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
1345 }
1346 }
1347
1348 // --- Wide letter spacing on body text ---
1349 if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
1350 if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
1351 const trackingEm = letterSpacingPx / fontSize;
1352 if (trackingEm > 0.05) {
1353 findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
1354 }
1355 }
1356 }
1357
1358 return findings;
1359}
1360
1361function checkElementQualityDOM(el) {
1362 const tag = el.tagName.toLowerCase();
1363 const style = getComputedStyle(el);
1364 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1365 const textLen = el.textContent?.trim().length || 0;
1366 // Browser getComputedStyle resolves everything to px — direct parseFloat
1367 // works.
1368 const fontSize = parseFloat(style.fontSize) || 16;
1369 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1370 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1371 const rect = el.getBoundingClientRect();
1372 const lineMax = (typeof window !== 'undefined' && window.__IMPECCABLE_CONFIG__?.lineLengthMax) || 80;
1373 const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
1374 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth });
1375}
1376
1377// Pure page-level skipped-heading walk. Takes a Document so it works in both
1378// the browser and jsdom.
1379function checkPageQualityFromDoc(doc) {
1380 const findings = [];
1381 const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
1382 let prevLevel = 0;
1383 let prevText = '';
1384 for (const h of headings) {
1385 const level = parseInt(h.tagName[1]);
1386 const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
1387 if (prevLevel > 0 && level > prevLevel + 1) {
1388 findings.push({
1389 id: 'skipped-heading',
1390 snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
1391 });
1392 }
1393 prevLevel = level;
1394 prevText = text;
1395 }
1396 return findings;
1397}
1398
1399// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
1400function checkPageQualityDOM() {
1401 return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
1402}
1403
1404// Node adapters — take pre-extracted jsdom computed style
1405
1406// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
1407// CSS length the rule needs ourselves (walking the parent chain for
1408// font-size inheritance), and pass `rect: null` to skip the two rules that
1409// genuinely need element rects (line-length, cramped-padding).
1410function checkElementQuality(el, style, tag, window) {
1411 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
1412 const textLen = el.textContent?.trim().length || 0;
1413 const fontSize = resolveFontSizePx(el, window);
1414 const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
1415 const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
1416 return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null });
1417}
1418
1419function checkElementBorders(tag, style, overrides, resolvedRadius) {
1420 const sides = ['Top', 'Right', 'Bottom', 'Left'];
1421 const widths = {}, colors = {};
1422 for (const s of sides) {
1423 widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1424 colors[s] = style[`border${s}Color`] || '';
1425 // jsdom silently drops any border shorthand containing var(), leaving
1426 // both width and color empty on the computed style. When the detectHtml
1427 // pre-pass pulled a resolved value off the rule, use it to fill in the
1428 // missing side so the side-tab check can run. Real browsers resolve
1429 // var() natively, so this fallback is a no-op in the browser path.
1430 if (widths[s] === 0 && overrides && overrides[s]) {
1431 widths[s] = overrides[s].width;
1432 colors[s] = overrides[s].color;
1433 } else if (colors[s] && colors[s].startsWith('var(') && overrides && overrides[s]) {
1434 // Longhand case: jsdom kept the width but left the color as the
1435 // literal `var(...)` string. Substitute the resolved color.
1436 colors[s] = overrides[s].color;
1437 }
1438 }
1439 // resolvedRadius lets the caller pre-resolve the radius via
1440 // resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
1441 // shorthand serialization. Falls back to the computed value for tests
1442 // and browser callers that don't pre-resolve.
1443 const radius = resolvedRadius != null
1444 ? resolvedRadius
1445 : (parseFloat(style.borderRadius) || 0);
1446 return checkBorders(tag, widths, colors, radius);
1447}
1448
1449function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
1450 const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1451 const hasDirectText = directText.trim().length > 0;
1452
1453 const effectiveBg = resolveBackground(el, window, customPropMap);
1454 // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
1455 // parseRgb misses Tailwind-tokenized text colors. Resolve through the
1456 // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
1457 let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
1458 if (!textColor) textColor = parseRgb(style.color);
1459
1460 // Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
1461 // blue }` at high specificity. The page's `a { color: inherit }` rule
1462 // (Tailwind v4 preflight) loses to jsdom even though it WINS in real
1463 // browsers (Chrome's UA wraps :link in :where() — zero specificity).
1464 // When the page declares the inherit rule AND we see jsdom's default
1465 // link blue on an anchor, walk to the nearest non-anchor ancestor and
1466 // use its color instead.
1467 if (
1468 hasAnchorInheritRule &&
1469 textColor &&
1470 textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
1471 (tag === 'a' || el.closest?.('a'))
1472 ) {
1473 let cur = el.parentElement;
1474 while (cur && cur.tagName !== 'HTML') {
1475 if (cur.tagName !== 'A') {
1476 const ps = window.getComputedStyle(cur);
1477 const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
1478 if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
1479 textColor = inh;
1480 break;
1481 }
1482 }
1483 cur = cur.parentElement;
1484 }
1485 }
1486
1487 return checkColors({
1488 tag,
1489 textColor,
1490 bgColor: readOwnBackgroundColor(el, style),
1491 effectiveBg,
1492 effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
1493 fontSize: parseFloat(style.fontSize) || 16,
1494 fontWeight: parseInt(style.fontWeight) || 400,
1495 hasDirectText,
1496 isEmojiOnly: isEmojiOnlyText(directText),
1497 bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
1498 bgImage: style.backgroundImage || '',
1499 classList: el.getAttribute?.('class') || el.className || '',
1500 });
1501}
1502
1503function checkElementIconTile(el, tag, window) {
1504 if (!HEADING_TAGS.has(tag)) return [];
1505 const sibling = el.previousElementSibling;
1506 if (!sibling) return [];
1507
1508 const sibStyle = window.getComputedStyle(sibling);
1509 // jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
1510 const sibWidth = parseFloat(sibStyle.width) || 0;
1511 const sibHeight = parseFloat(sibStyle.height) || 0;
1512
1513 const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
1514 let iconWidth = 0;
1515 if (iconChild) {
1516 const iconStyle = window.getComputedStyle(iconChild);
1517 iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
1518 }
1519 // Or: tile contains an emoji/symbol character directly as its only content
1520 const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1521 const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
1522
1523 return checkIconTile({
1524 headingTag: tag,
1525 headingText: el.textContent || '',
1526 headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
1527 siblingTag: sibling.tagName.toLowerCase(),
1528 siblingWidth: sibWidth,
1529 siblingHeight: sibHeight,
1530 siblingBottom: 0,
1531 siblingBgColor: parseRgb(sibStyle.backgroundColor),
1532 siblingBgImage: sibStyle.backgroundImage || '',
1533 siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
1534 siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
1535 hasIconChild: !!iconChild || hasInlineEmojiIcon,
1536 iconChildWidth: iconWidth,
1537 });
1538}
1539
1540function checkElementItalicSerif(el, style, tag) {
1541 if (tag !== 'h1' && tag !== 'h2') return [];
1542 return checkItalicSerif({
1543 tag,
1544 fontStyle: style.fontStyle || '',
1545 fontFamily: style.fontFamily || '',
1546 fontSize: parseFloat(style.fontSize) || 0,
1547 headingText: el.textContent || '',
1548 });
1549}
1550
1551function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
1552 if (tag !== 'h1') return [];
1553 const sibling = el.previousElementSibling;
1554 if (!sibling) return [];
1555 const sibStyle = window.getComputedStyle(sibling);
1556 // Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
1557 // etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
1558 // without resolution every style-based gate fails silently on Tailwind v4 builds.
1559 const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
1560 const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
1561 const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
1562 const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
1563 const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
1564 const siblingFontSize = parseFloat(fontSizeRaw) || 0;
1565 // resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
1566 // gate falls through cleanly. jsdom returns letter-spacing verbatim
1567 // (e.g. '0.15em'), unlike real browsers, so this conversion is required.
1568 return checkHeroEyebrow({
1569 headingTag: tag,
1570 headingText: el.textContent || '',
1571 headingFontSize: parseFloat(headingFontSizeRaw) || 0,
1572 siblingTag: sibling.tagName.toLowerCase(),
1573 siblingText: sibling.textContent || '',
1574 siblingTextTransform: sibStyle.textTransform || '',
1575 siblingFontSize,
1576 siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
1577 siblingFontWeight: fontWeightRaw || '',
1578 siblingColor: colorRaw || '',
1579 });
1580}
1581
1582function checkRepeatedSectionKickersFromDoc(doc, win) {
1583 const candidates = collectRepeatedSectionKickerCandidates(
1584 doc,
1585 (el) => win.getComputedStyle(el),
1586 (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1587 );
1588 return checkRepeatedSectionKickers({ candidates });
1589}
1590
1591function checkElementMotion(tag, style) {
1592 return checkMotion({
1593 tag,
1594 transitionProperty: style.transitionProperty || '',
1595 animationName: style.animationName || '',
1596 timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
1597 classList: '',
1598 });
1599}
1600
1601function checkElementGlow(tag, style, effectiveBg) {
1602 if (!style.boxShadow || style.boxShadow === 'none') return [];
1603 return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
1604}
1605
1606// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
1607
1608// Browser page-level checks — use document/getComputedStyle globals
1609
1610function checkTypography() {
1611 const findings = [];
1612
1613 // Walk actual text-bearing elements and tally font usage by *computed style*.
1614 // This is much more accurate than scanning CSS rules — it ignores rules that
1615 // exist in the stylesheet but apply to nothing (e.g. demo classes showing
1616 // anti-patterns), and counts what the user actually sees.
1617 const fontUsage = new Map(); // primary font name → count of elements
1618 let totalTextElements = 0;
1619 for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
1620 // Skip impeccable's own elements
1621 if (el.closest && el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1622 // Only count elements that actually have visible direct text
1623 const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
1624 if (!hasText) continue;
1625 const style = getComputedStyle(el);
1626 const ff = style.fontFamily;
1627 if (!ff) continue;
1628 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1629 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1630 if (!primary) continue;
1631 fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
1632 totalTextElements++;
1633 }
1634
1635 if (totalTextElements >= 20) {
1636 // A font is "primary" if it's used by at least 15% of text elements
1637 const PRIMARY_THRESHOLD = 0.15;
1638 for (const [font, count] of fontUsage) {
1639 const share = count / totalTextElements;
1640 if (share < PRIMARY_THRESHOLD) continue;
1641 if (!OVERUSED_FONTS.has(font)) continue;
1642 if (isBrandFontOnOwnDomain(font)) continue;
1643 findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1644 }
1645
1646 // Single-font check: only one distinct primary font across all text
1647 if (fontUsage.size === 1) {
1648 const only = [...fontUsage.keys()][0];
1649 findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1650 }
1651 }
1652
1653 const sizes = new Set();
1654 for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
1655 const fs = parseFloat(getComputedStyle(el).fontSize);
1656 if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
1657 }
1658 if (sizes.size >= 3) {
1659 const sorted = [...sizes].sort((a, b) => a - b);
1660 const ratio = sorted[sorted.length - 1] / sorted[0];
1661 if (ratio < 2.0) {
1662 findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1663 }
1664 }
1665
1666 return findings;
1667}
1668
1669function isCardLikeDOM(el) {
1670 const tag = el.tagName.toLowerCase();
1671 if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
1672 const style = getComputedStyle(el);
1673 const cls = el.getAttribute('class') || '';
1674 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
1675 const hasBorder = /\bborder\b/.test(cls);
1676 const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
1677 const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
1678 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1679}
1680
1681function checkLayout() {
1682 const findings = [];
1683 const flaggedEls = new Set();
1684
1685 for (const el of document.querySelectorAll('*')) {
1686 if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
1687 const cls = el.getAttribute('class') || '';
1688 const style = getComputedStyle(el);
1689 if (style.position === 'absolute' || style.position === 'fixed') continue;
1690 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1691 if ((el.textContent?.trim().length || 0) < 10) continue;
1692 const rect = el.getBoundingClientRect();
1693 if (rect.width < 50 || rect.height < 30) continue;
1694
1695 let parent = el.parentElement;
1696 while (parent) {
1697 if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
1698 parent = parent.parentElement;
1699 }
1700 }
1701
1702 for (const el of flaggedEls) {
1703 let isAncestor = false;
1704 for (const other of flaggedEls) {
1705 if (other !== el && el.contains(other)) { isAncestor = true; break; }
1706 }
1707 if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
1708 }
1709
1710 return findings;
1711}
1712
1713// Node page-level checks — take document/window as parameters
1714
1715function checkPageTypography(doc, win) {
1716 const findings = [];
1717
1718 const fonts = new Set();
1719 const overusedFound = new Set();
1720
1721 for (const sheet of doc.styleSheets) {
1722 let rules;
1723 try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
1724 if (!rules) continue;
1725 for (const rule of rules) {
1726 if (rule.type !== 1) continue;
1727 const ff = rule.style?.fontFamily;
1728 if (!ff) continue;
1729 const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1730 const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
1731 if (primary) {
1732 fonts.add(primary);
1733 if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
1734 }
1735 }
1736 }
1737
1738 // Check Google Fonts links in HTML
1739 const html = doc.documentElement?.outerHTML || '';
1740 const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
1741 let m;
1742 while ((m = gfRe.exec(html)) !== null) {
1743 const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
1744 for (const f of families) {
1745 fonts.add(f);
1746 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1747 }
1748 }
1749
1750 // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
1751 const ffRe = /font-family\s*:\s*([^;}]+)/gi;
1752 let fm;
1753 while ((fm = ffRe.exec(html)) !== null) {
1754 for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
1755 if (f && !GENERIC_FONTS.has(f)) {
1756 fonts.add(f);
1757 if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
1758 }
1759 }
1760 }
1761
1762 for (const font of overusedFound) {
1763 findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
1764 }
1765
1766 // Single font
1767 if (fonts.size === 1) {
1768 const els = doc.querySelectorAll('*');
1769 if (els.length >= 20) {
1770 findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
1771 }
1772 }
1773
1774 // Flat type hierarchy
1775 const sizes = new Set();
1776 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
1777 for (const el of textEls) {
1778 const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
1779 // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
1780 if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
1781 }
1782 if (sizes.size >= 3) {
1783 const sorted = [...sizes].sort((a, b) => a - b);
1784 const ratio = sorted[sorted.length - 1] / sorted[0];
1785 if (ratio < 2.0) {
1786 findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
1787 }
1788 }
1789
1790 return findings;
1791}
1792
1793function isCardLike(el, win) {
1794 const tag = el.tagName.toLowerCase();
1795 if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
1796
1797 const style = win.getComputedStyle(el);
1798 const rawStyle = el.getAttribute?.('style') || '';
1799 const cls = el.getAttribute?.('class') || '';
1800
1801 const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
1802 /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
1803 const hasBorder = /\bborder\b/.test(cls);
1804 const widthPx = parseFloat(style.width) || 0;
1805 const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||
1806 /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
1807 const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
1808 /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
1809
1810 return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
1811}
1812
1813function checkPageLayout(doc, win) {
1814 const findings = [];
1815
1816 // Nested cards
1817 const allEls = doc.querySelectorAll('*');
1818 const flaggedEls = new Set();
1819 for (const el of allEls) {
1820 if (!isCardLike(el, win)) continue;
1821 if (flaggedEls.has(el)) continue;
1822
1823 const tag = el.tagName.toLowerCase();
1824 const cls = el.getAttribute?.('class') || '';
1825 const rawStyle = el.getAttribute?.('style') || '';
1826
1827 if (['pre', 'code'].includes(tag)) continue;
1828 if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
1829 if ((el.textContent?.trim().length || 0) < 10) continue;
1830 if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
1831
1832 // Walk up to find card-like ancestor
1833 let parent = el.parentElement;
1834 while (parent) {
1835 if (isCardLike(parent, win)) {
1836 flaggedEls.add(el);
1837 break;
1838 }
1839 parent = parent.parentElement;
1840 }
1841 }
1842
1843 // Only report innermost nested cards
1844 for (const el of flaggedEls) {
1845 let isAncestorOfFlagged = false;
1846 for (const other of flaggedEls) {
1847 if (other !== el && el.contains(other)) {
1848 isAncestorOfFlagged = true;
1849 break;
1850 }
1851 }
1852 if (!isAncestorOfFlagged) {
1853 findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
1854 }
1855 }
1856
1857 // Everything centered
1858 const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
1859 let centeredCount = 0;
1860 let totalText = 0;
1861 for (const el of textEls) {
1862 const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
1863 if (!hasDirectText) continue;
1864 totalText++;
1865
1866 let cur = el;
1867 let isCentered = false;
1868 while (cur && cur.nodeType === 1) {
1869 const rawStyle = cur.getAttribute?.('style') || '';
1870 const cls = cur.getAttribute?.('class') || '';
1871 if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
1872 isCentered = true;
1873 break;
1874 }
1875 if (cur.tagName === 'BODY') break;
1876 cur = cur.parentElement;
1877 }
1878 if (isCentered) centeredCount++;
1879 }
1880
1881 if (totalText >= 5 && centeredCount / totalText > 0.7) {
1882 findings.push({
1883 id: 'everything-centered',
1884 snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
1885 });
1886 }
1887
1888 return findings;
1889}
1890
1891export {
1892 checkBorders,
1893 isEmojiOnlyText,
1894 checkColors,
1895 isCardLikeFromProps,
1896 checkIconTile,
1897 resolveSerif,
1898 checkItalicSerif,
1899 isAccentColor,
1900 checkHeroEyebrow,
1901 checkRepeatedSectionKickers,
1902 checkMotion,
1903 checkGlow,
1904 checkHtmlPatterns,
1905 readOwnBackgroundColor,
1906 resolveBackground,
1907 resolveGradientStops,
1908 parseRadiusToPx,
1909 resolveBorderRadiusPx,
1910 checkElementBordersDOM,
1911 checkElementColorsDOM,
1912 checkElementIconTileDOM,
1913 checkElementItalicSerifDOM,
1914 checkElementHeroEyebrowDOM,
1915 buildCustomPropMap,
1916 resolveVarRefs,
1917 oklchToRgb,
1918 parseAnyColor,
1919 parseColorResolved,
1920 cleanInlineText,
1921 isRepeatedKickerCandidate,
1922 collectRepeatedSectionKickerCandidates,
1923 checkRepeatedSectionKickersDOM,
1924 checkElementMotionDOM,
1925 checkElementGlowDOM,
1926 checkElementAIPaletteDOM,
1927 resolveFontSizePx,
1928 resolveLengthPx,
1929 checkQuality,
1930 checkElementQualityDOM,
1931 checkPageQualityFromDoc,
1932 checkPageQualityDOM,
1933 checkElementQuality,
1934 checkElementBorders,
1935 checkElementColors,
1936 checkElementIconTile,
1937 checkElementItalicSerif,
1938 checkElementHeroEyebrow,
1939 checkRepeatedSectionKickersFromDoc,
1940 checkElementMotion,
1941 checkElementGlow,
1942 checkTypography,
1943 isCardLikeDOM,
1944 checkLayout,
1945 checkPageTypography,
1946 isCardLike,
1947 checkPageLayout,
1948};