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