diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs index 2913c4b1b..d67f493c8 100644 --- a/.claude/skills/critique/scripts/detect-antipatterns.mjs +++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs @@ -3,14 +3,15 @@ /** * Anti-Pattern Detector for Impeccable * - * Scans files/directories for known UI anti-patterns (starting with "side-tab"). - * Used by the critique skill and as a future hook. + * Scans HTML files using jsdom (computed styles) by default, + * with regex fallback for non-HTML files (CSS, JSX, TSX). + * URLs are scanned via Puppeteer for full browser rendering. * * Usage: - * node detect-antipatterns.mjs [file-or-dir...] # scan files/dirs - * node detect-antipatterns.mjs # scan cwd + * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest + * node detect-antipatterns.mjs https://... # Puppeteer (auto) + * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom) * node detect-antipatterns.mjs --json # JSON output - * echo '{"tool_input":{"file_path":"f.html"}}' | node detect-antipatterns.mjs # stdin * * Exit codes: 0 = clean, 2 = findings */ @@ -19,24 +20,406 @@ import fs from 'fs'; import path from 'path'; // --------------------------------------------------------------------------- -// Line-level context helpers +// Shared constants +// --------------------------------------------------------------------------- + +const SAFE_TAGS = new Set([ + 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', + 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', + 'button', 'hr', 'html', 'head', 'body', 'script', 'style', + 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', + 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', +]); + +const OVERUSED_FONTS = new Set([ + 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', +]); + +const GENERIC_FONTS = new Set([ + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', + 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', + '-apple-system', 'blinkmacsystemfont', 'segoe ui', + 'inherit', 'initial', 'unset', 'revert', +]); + +// --------------------------------------------------------------------------- +// Anti-pattern definitions +// --------------------------------------------------------------------------- + +const ANTIPATTERNS = [ + { + id: 'side-tab', + name: 'Side-tab accent border', + description: + 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', + }, + { + id: 'border-accent-on-rounded', + name: 'Border accent on rounded element', + description: + 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', + }, + { + id: 'overused-font', + name: 'Overused font', + description: + 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', + }, + { + id: 'single-font', + name: 'Single font for everything', + description: + 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', + }, + { + id: 'flat-type-hierarchy', + name: 'Flat type hierarchy', + description: + 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', + }, +]; + +function getAP(id) { + return ANTIPATTERNS.find(a => a.id === id); +} + +function finding(id, filePath, snippet, line = 0) { + const ap = getAP(id); + return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet }; +} + +// --------------------------------------------------------------------------- +// Computed-style detection (shared by jsdom + Puppeteer + browser) +// --------------------------------------------------------------------------- + +/** + * Check if an RGB color string is neutral (gray/structural). + */ +function isNeutralColor(color) { + if (!color || color === 'transparent') return true; + const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!m) return true; + const [r, g, b] = [+m[1], +m[2], +m[3]]; + return (Math.max(r, g, b) - Math.min(r, g, b)) < 30; +} + +/** + * Analyze a single element's computed styles for border anti-patterns. + * Returns array of { id, snippet } findings. + */ +function checkElementBorders(tag, style) { + if (SAFE_TAGS.has(tag)) return []; + const findings = []; + + const sides = ['Top', 'Right', 'Bottom', 'Left']; + const widths = {}; + const colors = {}; + for (const s of sides) { + widths[s] = parseFloat(style[`border${s}Width`]) || 0; + colors[s] = style[`border${s}Color`] || ''; + } + const radius = parseFloat(style.borderRadius) || 0; + + for (const side of sides) { + const w = widths[side]; + if (w < 1) continue; + if (isNeutralColor(colors[side])) continue; + + const otherSides = sides.filter(s => s !== side); + const maxOther = Math.max(...otherSides.map(s => widths[s])); + const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2); + if (!isAccent) continue; + + const sideName = side.toLowerCase(); + const isSide = side === 'Left' || side === 'Right'; + + if (isSide) { + if (radius > 0) { + findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); + } else if (w >= 3) { + findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px` }); + } + } else { + if (radius > 0 && w >= 2) { + findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); + } + } + } + + return findings; +} + +/** + * Page-level typography checks using the document/window API. + * Returns array of { id, snippet } findings. + */ +function checkPageTypography(document, window) { + const findings = []; + + // --- Overused fonts --- + const fonts = new Set(); + const overusedFound = new Set(); + + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules || sheet.rules; } catch { continue; } + if (!rules) continue; + for (const rule of rules) { + if (rule.type !== 1) continue; + const ff = rule.style?.fontFamily; + if (!ff) continue; + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); + const primary = stack.find(f => f && !GENERIC_FONTS.has(f)); + if (primary) { + fonts.add(primary); + if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary); + } + } + } + + // Check Google Fonts links in HTML + const html = document.documentElement?.outerHTML || ''; + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; + let m; + while ((m = gfRe.exec(html)) !== null) { + const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); + for (const f of families) { + fonts.add(f); + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); + } + } + + // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM) + const ffRe = /font-family\s*:\s*([^;}]+)/gi; + let fm; + while ((fm = ffRe.exec(html)) !== null) { + for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { + if (f && !GENERIC_FONTS.has(f)) { + fonts.add(f); + if (OVERUSED_FONTS.has(f)) overusedFound.add(f); + } + } + } + + for (const font of overusedFound) { + findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` }); + } + + // --- Single font --- + if (fonts.size === 1) { + const els = document.querySelectorAll('*'); + if (els.length >= 20) { + findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` }); + } + } + + // --- Flat type hierarchy --- + const sizes = new Set(); + const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div'); + for (const el of textEls) { + const fs = parseFloat(window.getComputedStyle(el).fontSize); + // Filter out sub-8px values (jsdom doesn't resolve relative units properly) + if (fs >= 8 && fs < 200) sizes.add(Math.round(fs * 10) / 10); + } + if (sizes.size >= 3) { + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio < 2.0) { + findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); + } + } + + return findings; +} + +// --------------------------------------------------------------------------- +// jsdom detection (default for HTML files) +// --------------------------------------------------------------------------- + +async function detectHtml(filePath) { + let JSDOM; + try { + ({ JSDOM } = await import('jsdom')); + } catch { + // jsdom not available — fall back to regex + const content = fs.readFileSync(filePath, 'utf-8'); + return detectText(content, filePath); + } + + const html = fs.readFileSync(filePath, 'utf-8'); + const resolvedPath = path.resolve(filePath); + const fileDir = path.dirname(resolvedPath); + + // Inline linked local stylesheets so jsdom can see them + let processedHtml = html; + const linkRes = [ + /]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi, + /]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi, + ]; + for (const re of linkRes) { + let m; + while ((m = re.exec(html)) !== null) { + const href = m[1]; + if (/^(https?:)?\/\//.test(href)) continue; + const cssPath = path.resolve(fileDir, href); + try { + const css = fs.readFileSync(cssPath, 'utf-8'); + processedHtml = processedHtml.replace(m[0], ``); + } catch { /* skip unreadable */ } + } + } + + const dom = new JSDOM(processedHtml, { + url: `file://${resolvedPath}`, + pretendToBeVisual: true, + }); + const { window } = dom; + const { document } = window; + + await new Promise(r => setTimeout(r, 50)); + + const findings = []; + + // Element-level border checks + for (const el of document.querySelectorAll('*')) { + const tag = el.tagName.toLowerCase(); + const style = window.getComputedStyle(el); + for (const f of checkElementBorders(tag, style)) { + findings.push(finding(f.id, filePath, f.snippet)); + } + } + + // Page-level typography checks + for (const f of checkPageTypography(document, window)) { + findings.push(finding(f.id, filePath, f.snippet)); + } + + window.close(); + return findings; +} + +// --------------------------------------------------------------------------- +// Puppeteer detection (for URLs) +// --------------------------------------------------------------------------- + +async function detectUrl(url) { + let puppeteer; + try { + puppeteer = await import('puppeteer'); + } catch { + throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); + } + + const browser = await puppeteer.default.launch({ headless: true }); + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); + + // Serialize shared functions for page.evaluate + const safeTags = [...SAFE_TAGS]; + const overusedFonts = [...OVERUSED_FONTS]; + const genericFonts = [...GENERIC_FONTS]; + + const results = await page.evaluate((safeTags, overusedFonts, genericFonts) => { + const safe = new Set(safeTags); + const overused = new Set(overusedFonts); + const generic = new Set(genericFonts); + const findings = []; + const sides = ['Top', 'Right', 'Bottom', 'Left']; + + // Element-level border checks + for (const el of document.querySelectorAll('*')) { + const tag = el.tagName.toLowerCase(); + if (safe.has(tag)) continue; + const rect = el.getBoundingClientRect(); + if (rect.width < 20 || rect.height < 20) continue; + + const style = getComputedStyle(el); + const widths = {}, colors = {}; + for (const s of sides) { + widths[s] = parseFloat(style[`border${s}Width`]) || 0; + colors[s] = style[`border${s}Color`] || ''; + } + const radius = parseFloat(style.borderRadius) || 0; + + for (const side of sides) { + const w = widths[side]; + if (w < 1) continue; + const c = colors[side]; + if (!c || c === 'transparent') continue; + const cm = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (cm && (Math.max(+cm[1], +cm[2], +cm[3]) - Math.min(+cm[1], +cm[2], +cm[3])) < 30) continue; + + const others = sides.filter(s => s !== side); + const maxOther = Math.max(...others.map(s => widths[s])); + if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; + + const sn = side.toLowerCase(); + const isSide = side === 'Left' || side === 'Right'; + if (isSide) { + if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); + else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); + } else { + if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); + } + } + } + + // Typography checks + const fonts = new Set(); + const overusedFound = new Set(); + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch { continue; } + if (!rules) continue; + for (const rule of rules) { + if (rule.type !== 1) continue; + const ff = rule.style?.fontFamily; + if (!ff) continue; + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); + const primary = stack.find(f => f && !generic.has(f)); + if (primary) { + fonts.add(primary); + if (overused.has(primary)) overusedFound.add(primary); + } + } + } + for (const f of overusedFound) findings.push({ id: 'overused-font', snippet: `Primary font: ${f}` }); + if (fonts.size === 1 && document.querySelectorAll('*').length > 20) { + findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` }); + } + + const sizes = new Set(); + for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { + const fs = parseFloat(getComputedStyle(el).fontSize); + if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); + } + if (sizes.size >= 3) { + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio < 2.0) findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); + } + + return findings; + }, safeTags, overusedFonts, genericFonts); + + await browser.close(); + return results.map(f => finding(f.id, url, f.snippet)); +} + +// --------------------------------------------------------------------------- +// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.) // --------------------------------------------------------------------------- /** Check if Tailwind `rounded-*` appears on the same line */ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); - -/** Check if CSS `border-radius` appears on the same line (inline styles) */ const hasBorderRadius = (line) => /border-radius/i.test(line); +const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); -/** Check if line contains an HTML element that legitimately uses side borders */ -const SAFE_ELEMENTS = /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i; -const isSafeElement = (line) => SAFE_ELEMENTS.test(line); - -/** Check if the border color in a CSS declaration looks neutral (gray/structural) */ -function isNeutralBorderColor(matchStr) { - const colorMatch = matchStr.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); - if (!colorMatch) return false; - const c = colorMatch[1].toLowerCase(); +function isNeutralBorderColor(str) { + const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); + if (!m) return false; + const c = m[1].toLowerCase(); if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); if (hex) { @@ -51,553 +434,119 @@ function isNeutralBorderColor(matchStr) { return false; } -// --------------------------------------------------------------------------- -// Anti-pattern definitions -// --------------------------------------------------------------------------- +const REGEX_MATCHERS = [ + // --- Side-tab --- + { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, + test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; }, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, + test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; }, + fmt: (m) => m[0].replace(/\s*;?\s*$/, '') }, + { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, + test: (m) => +m[1] >= 3, + fmt: (m) => m[0] }, + // --- Border accent on rounded --- + { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g, + test: (m, line) => hasRounded(line) && +m[1] >= 1, + fmt: (m) => m[0] }, + { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => +m[1] >= 3 && hasBorderRadius(line), + fmt: (m) => m[0] }, + // --- Overused font --- + { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi, + test: () => true, + fmt: (m) => m[0] }, + { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi, + test: () => true, + fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` }, +]; -const ANTIPATTERNS = [ - { - id: 'side-tab', - name: 'Side-tab accent border', - description: - 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', - matchers: [ - // Tailwind: border-[lrse]-N — threshold depends on context - // With rounded: any N >= 1 (even thin borders look wrong on rounded cards) - // Without rounded: N >= 4 (thick enough to always be suspicious) - { - regex: /\bborder-[lrse]-(\d+)\b/g, - test: (match, line) => { - const n = parseInt(match[1], 10); - if (hasRounded(line)) return n >= 1; - return n >= 4; - }, - format: (match) => match[0], - }, - // CSS shorthand: border-left/right: Npx solid [color] - { - regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - if (isNeutralBorderColor(match[0])) return false; - const n = parseInt(match[1], 10); - if (hasBorderRadius(line)) return n >= 1; - return n >= 3; - }, - format: (match) => match[0].replace(/\s*;?\s*$/, ''), - }, - // CSS longhand: border-left/right-width: Npx - { - regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // CSS logical: border-inline-start/end: Npx solid - { - regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // CSS logical longhand: border-inline-start/end-width: Npx - { - regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // JSX inline: borderLeft/borderRight with thickness - { - regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, - test: (match) => parseInt(match[1], 10) >= 3, - format: (match) => match[0], - }, - ], +const REGEX_ANALYZERS = [ + // Single font + (content, filePath) => { + const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; + const fonts = new Set(); + let m; + while ((m = fontFamilyRe.exec(content)) !== null) { + for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { + if (f && !GENERIC_FONTS.has(f)) fonts.add(f); + } + } + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; + while ((m = gfRe.exec(content)) !== null) { + for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f); + } + if (fonts.size !== 1 || content.split('\n').length < 20) return []; + const name = [...fonts][0]; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } } + return [finding('single-font', filePath, `Only font: ${name}`, line)]; }, - { - id: 'border-accent-on-rounded', - name: 'Border accent on rounded element', - description: - 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', - matchers: [ - // Tailwind: border-[tb]-N + rounded-* on same line - { - regex: /\bborder-[tb]-(\d+)\b/g, - test: (match, line) => { - const n = parseInt(match[1], 10); - return hasRounded(line) && n >= 1; - }, - format: (match) => match[0], - }, - // CSS: border-top/bottom with border-radius on same line (inline styles) - { - regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, - test: (match, line) => { - const n = parseInt(match[1], 10); - return n >= 3 && hasBorderRadius(line); - }, - format: (match) => match[0], - }, - ], - }, - // ------------------------------------------------------------------------- - // Typography anti-patterns - // ------------------------------------------------------------------------- - { - id: 'overused-font', - name: 'Overused font', - description: - 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', - matchers: [ - // CSS font-family: 'Inter' as primary (first) font - { - regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi, - test: () => true, - format: (match) => match[0], - }, - // Google Fonts import/link - { - regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi, - test: () => true, - format: (match) => `Google Fonts: ${match[1].replace(/\+/g, ' ')}`, - }, - ], - }, - { - id: 'single-font', - name: 'Single font for everything', - description: - 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', - analyzers: [ - (content, filePath) => { - // Extract all font names from font-family declarations - const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; - const GENERIC = new Set([ - 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', - 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', - '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', 'unset', - ]); - const fonts = new Set(); - let m; - while ((m = fontFamilyRe.exec(content)) !== null) { - // Extract individual font names from the stack - const stack = m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); - for (const f of stack) { - if (f && !GENERIC.has(f)) fonts.add(f); - } - } - - // Also extract from Google Fonts imports - const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; - while ((m = gfRe.exec(content)) !== null) { - const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); - for (const f of families) fonts.add(f); - } - - // Only flag if the file has meaningful content and exactly 1 font - if (fonts.size !== 1) return []; - // Don't flag tiny files (likely components) - const lineCount = content.split('\n').length; - if (lineCount < 20) return []; - - const fontName = [...fonts][0]; - // Find the first line where this font appears for reporting - const lines = content.split('\n'); - let reportLine = 1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].toLowerCase().includes(fontName)) { - reportLine = i + 1; - break; - } - } - - return [{ - antipattern: 'single-font', - name: 'Single font for everything', - description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', - file: filePath, - line: reportLine, - snippet: `Only font: ${fontName}`, - }]; - }, - ], - }, - { - id: 'flat-type-hierarchy', - name: 'Flat type hierarchy', - description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', - analyzers: [ - (content, filePath) => { - // Collect all font-size values and convert to px - const sizes = new Set(); - const REM_BASE = 16; - const lines = content.split('\n'); - - // CSS font-size declarations - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - let m; - while ((m = sizeRe.exec(content)) !== null) { - const val = parseFloat(m[1]); - const unit = m[2].toLowerCase(); - const px = unit === 'px' ? val : val * REM_BASE; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - - // clamp() — extract min and max - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - const minVal = parseFloat(m[1]); - const minUnit = m[2].toLowerCase(); - const maxVal = parseFloat(m[3]); - const maxUnit = m[4].toLowerCase(); - sizes.add(Math.round((minUnit === 'px' ? minVal : minVal * REM_BASE) * 10) / 10); - sizes.add(Math.round((maxUnit === 'px' ? maxVal : maxVal * REM_BASE) * 10) / 10); - } - - // Tailwind text-* classes → approximate px values - const TW_SIZES = { - 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, - 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, - 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128, - }; - for (const [cls, px] of Object.entries(TW_SIZES)) { - const twRe = new RegExp(`\\b${cls}\\b`); - if (twRe.test(content)) sizes.add(px); - } - - // Need at least 3 distinct sizes to evaluate hierarchy - if (sizes.size < 3) return []; - - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - - // A healthy hierarchy has at least 2x range (e.g., 14px body to 36px heading) - if (ratio >= 2.0) return []; - - // Find line to report on (first font-size declaration) - let reportLine = 1; - for (let i = 0; i < lines.length; i++) { - if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { - reportLine = i + 1; - break; - } - } - - return [{ - antipattern: 'flat-type-hierarchy', - name: 'Flat type hierarchy', - description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', - file: filePath, - line: reportLine, - snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, - }]; - }, - ], + // Flat type hierarchy + (content, filePath) => { + const sizes = new Set(); + const REM = 16; + let m; + const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; + while ((m = sizeRe.exec(content)) !== null) { + const px = m[2] === 'px' ? +m[1] : +m[1] * REM; + if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); + } + const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; + while ((m = clampRe.exec(content)) !== null) { + sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); + sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); + } + const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; + for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } + if (sizes.size < 3) return []; + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio >= 2.0) return []; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } + return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; }, ]; -// --------------------------------------------------------------------------- -// Detection engine -// --------------------------------------------------------------------------- - /** - * Scan content for anti-patterns. - * @param {string} content File content - * @param {string} filePath File path (for reporting) - * @returns {Array<{antipattern: string, name: string, description: string, file: string, line: number, snippet: string}>} + * Regex-based detection for non-HTML files or --fast mode. */ -function detectAntiPatterns(content, filePath) { +function detectText(content, filePath) { const findings = []; const lines = content.split('\n'); - for (const ap of ANTIPATTERNS) { - // Line-level matchers - if (ap.matchers) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - for (const matcher of ap.matchers) { - // Reset regex state for each line - matcher.regex.lastIndex = 0; - let m; - while ((m = matcher.regex.exec(line)) !== null) { - if (matcher.test(m, line)) { - findings.push({ - antipattern: ap.id, - name: ap.name, - description: ap.description, - file: filePath, - line: i + 1, - snippet: matcher.format(m), - }); - } - } + for (const matcher of REGEX_MATCHERS) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + matcher.regex.lastIndex = 0; + let m; + while ((m = matcher.regex.exec(line)) !== null) { + if (matcher.test(m, line)) { + findings.push(finding(matcher.id, filePath, matcher.fmt(m), i + 1)); } } } + } - // File-level analyzers - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - findings.push(...analyzer(content, filePath)); - } - } + for (const analyzer of REGEX_ANALYZERS) { + findings.push(...analyzer(content, filePath)); } return findings; } -// --------------------------------------------------------------------------- -// Deep detection (jsdom / Puppeteer — computed styles) -// --------------------------------------------------------------------------- - -const SAFE_TAGS_DEEP = new Set([ - 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', - 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', - 'button', 'hr', 'html', 'head', 'body', 'script', 'style', - 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', -]); - -/** - * Analyze a single DOM element using computed styles. - * Works with both jsdom window and Puppeteer page. - */ -function analyzeElementDeep(el, computedStyle, filePath) { - const findings = []; - const tag = el.tagName.toLowerCase(); - if (SAFE_TAGS_DEEP.has(tag)) return findings; - - const sides = ['Top', 'Right', 'Bottom', 'Left']; - const widths = {}; - const colors = {}; - for (const s of sides) { - widths[s] = parseFloat(computedStyle[`border${s}Width`]) || 0; - colors[s] = computedStyle[`border${s}Color`] || ''; - } - const radius = parseFloat(computedStyle.borderRadius) || 0; - const fontSize = parseFloat(computedStyle.fontSize) || 0; - const fontFamily = computedStyle.fontFamily || ''; - - // --- Border accent detection --- - for (const side of sides) { - const w = widths[side]; - if (w < 1) continue; - - // Check if border color is transparent - const color = colors[side]; - if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue; - - // Check if neutral (gray) - const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbMatch) { - const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]]; - if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue; - } - - const otherSides = sides.filter(s => s !== side); - const maxOther = Math.max(...otherSides.map(s => widths[s])); - const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2); - if (!isAccent) continue; - - const isSide = side === 'Left' || side === 'Right'; - const sideName = side.toLowerCase(); - - if (isSide) { - if (radius > 0) { - findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } else if (w >= 3) { - findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px` }); - } - } else { - if (radius > 0 && w >= 2) { - findings.push({ antipattern: 'border-accent-on-rounded', name: 'Border accent on rounded element', description: ANTIPATTERNS[1].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } - } - } - - return findings; -} - -/** - * Deep scan using jsdom — resolves linked stylesheets, computes styles. - * @param {string} filePath Path to an HTML file - * @returns {Promise} findings - */ -async function detectAntiPatternsDeep(filePath) { - let JSDOM; - try { - ({ JSDOM } = await import('jsdom')); - } catch { - throw new Error('jsdom is required for --deep mode. Install it: npm install jsdom'); - } - - const html = fs.readFileSync(filePath, 'utf-8'); - const resolvedPath = path.resolve(filePath); - const fileDir = path.dirname(resolvedPath); - - // Resolve linked stylesheets and inline them - let processedHtml = html; - const linkRe = /]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi; - const linkRe2 = /]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi; - for (const re of [linkRe, linkRe2]) { - let m; - while ((m = re.exec(html)) !== null) { - const href = m[1]; - // Only resolve local files, not URLs - if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) continue; - const cssPath = path.resolve(fileDir, href); - try { - const cssContent = fs.readFileSync(cssPath, 'utf-8'); - processedHtml = processedHtml.replace(m[0], ``); - } catch { - // Can't read stylesheet, skip - } - } - } - - const dom = new JSDOM(processedHtml, { - url: `file://${resolvedPath}`, - resources: 'usable', - pretendToBeVisual: true, - }); - - const { window } = dom; - const { document } = window; - - // Wait for styles to apply - await new Promise(r => setTimeout(r, 100)); - - const findings = []; - const elements = document.querySelectorAll('*'); - - for (const el of elements) { - const style = window.getComputedStyle(el); - findings.push(...analyzeElementDeep(el, style, filePath)); - } - - // Also run file-level analyzers (overused fonts, single font, flat hierarchy) - // These work on the raw content which is fine - for (const ap of ANTIPATTERNS) { - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - findings.push(...analyzer(html, filePath)); - } - } - } - - window.close(); - return findings; -} - -/** - * Deep scan using Puppeteer — full browser rendering for URLs. - * @param {string} url URL to scan - * @returns {Promise} findings - */ -async function detectAntiPatternsUrl(url) { - let puppeteer; - try { - puppeteer = await import('puppeteer'); - } catch { - throw new Error('puppeteer is required for URL scanning. Install it: npm install puppeteer'); - } - - const browser = await puppeteer.default.launch({ headless: true }); - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); - - // Run detection in the browser context - const findings = await page.evaluate((safeTags) => { - const results = []; - const safe = new Set(safeTags); - const sides = ['Top', 'Right', 'Bottom', 'Left']; - const elements = document.querySelectorAll('*'); - - for (const el of elements) { - const tag = el.tagName.toLowerCase(); - if (safe.has(tag)) continue; - const rect = el.getBoundingClientRect(); - if (rect.width < 20 || rect.height < 20) continue; - - const style = getComputedStyle(el); - const widths = {}; - const colors = {}; - for (const s of sides) { - widths[s] = parseFloat(style[`border${s}Width`]) || 0; - colors[s] = style[`border${s}Color`] || ''; - } - const radius = parseFloat(style.borderRadius) || 0; - - for (const side of sides) { - const w = widths[side]; - if (w < 1) continue; - const color = colors[side]; - if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue; - const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbMatch) { - const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]]; - if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue; - } - - const otherSides = sides.filter(s => s !== side); - const maxOther = Math.max(...otherSides.map(s => widths[s])); - const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2); - if (!isAccent) continue; - - const isSide = side === 'Left' || side === 'Right'; - const sideName = side.toLowerCase(); - - if (isSide) { - if (radius > 0) { - results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } else if (w >= 3) { - results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px` }); - } - } else { - if (radius > 0 && w >= 2) { - results.push({ antipattern: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } - } - } - } - - return results; - }, [...SAFE_TAGS_DEEP]); - - // Enrich findings with metadata - const enriched = findings.map(f => ({ - ...f, - name: f.antipattern === 'side-tab' ? 'Side-tab accent border' : 'Border accent on rounded element', - description: ANTIPATTERNS.find(a => a.id === f.antipattern)?.description || '', - file: url, - line: 0, - })); - - // Also get the page HTML for file-level analyzers - const html = await page.content(); - for (const ap of ANTIPATTERNS) { - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - enriched.push(...analyzer(html, url)); - } - } - } - - await browser.close(); - return enriched; -} - // --------------------------------------------------------------------------- // File walker // --------------------------------------------------------------------------- @@ -613,23 +562,17 @@ const SCANNABLE_EXTENSIONS = new Set([ '.vue', '.svelte', '.astro', ]); +const HTML_EXTENSIONS = new Set(['.html', '.htm']); + function walkDir(dir) { const files = []; let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return files; - } + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { - if (entry.name.startsWith('.') && SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue; const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...walkDir(full)); - } else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { - files.push(full); - } + if (entry.isDirectory()) files.push(...walkDir(full)); + else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full); } return files; } @@ -639,61 +582,42 @@ function walkDir(dir) { // --------------------------------------------------------------------------- function formatFindings(findings, jsonMode) { - if (jsonMode) { - return JSON.stringify(findings, null, 2); - } + if (jsonMode) return JSON.stringify(findings, null, 2); const grouped = {}; for (const f of findings) { if (!grouped[f.file]) grouped[f.file] = []; grouped[f.file].push(f); } - - const lines = []; + const out = []; for (const [file, items] of Object.entries(grouped)) { - lines.push(`\n${file}`); + out.push(`\n${file}`); for (const item of items) { - lines.push(` line ${item.line}: [${item.antipattern}] ${item.snippet}`); - lines.push(` → ${item.description}`); + out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); + out.push(` → ${item.description}`); } } - - const count = findings.length; - lines.push(`\n${count} anti-pattern${count === 1 ? '' : 's'} found.`); - return lines.join('\n'); + out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`); + return out.join('\n'); } // --------------------------------------------------------------------------- -// Stdin handling (for future hook use) +// Stdin handling // --------------------------------------------------------------------------- -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf-8'); -} - async function handleStdin() { - const input = await readStdin(); - let parsed; + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const input = Buffer.concat(chunks).toString('utf-8'); try { - parsed = JSON.parse(input); - } catch { - // Not JSON — treat as raw content - return detectAntiPatterns(input, ''); - } - - // Hook format: { tool_input: { file_path: "..." } } - const filePath = parsed?.tool_input?.file_path; - if (filePath && fs.existsSync(filePath)) { - const content = fs.readFileSync(filePath, 'utf-8'); - return detectAntiPatterns(content, filePath); - } - - // Fallback: scan the raw JSON as content - return detectAntiPatterns(input, ''); + const parsed = JSON.parse(input); + const fp = parsed?.tool_input?.file_path; + if (fp && fs.existsSync(fp)) { + return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) + ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp); + } + } catch { /* not JSON */ } + return detectText(input, ''); } // --------------------------------------------------------------------------- @@ -706,108 +630,89 @@ function printUsage() { Scan files or URLs for known UI anti-patterns. Options: - --deep Use jsdom for computed style analysis (catches linked stylesheets) + --fast Regex-only mode (skip jsdom, faster but misses linked stylesheets) --json Output results as JSON --help Show this help message -Modes: - file/dir Fast regex scan (default) - file + --deep jsdom computed styles (resolves local CSS) - https://... Puppeteer full browser (auto, resolves everything) +Detection modes: + HTML files jsdom with computed styles (default, catches linked CSS) + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) + URLs Puppeteer full browser rendering (auto-detected) + --fast Forces regex for all files Examples: node detect-antipatterns.mjs src/ - node detect-antipatterns.mjs --deep index.html + node detect-antipatterns.mjs index.html node detect-antipatterns.mjs https://example.com - node detect-antipatterns.mjs --json .`); -} - -function isUrl(str) { - return /^https?:\/\//i.test(str); + node detect-antipatterns.mjs --fast --json .`); } async function main() { const args = process.argv.slice(2); const jsonMode = args.includes('--json'); const helpMode = args.includes('--help'); - const deepMode = args.includes('--deep'); - const targets = args.filter((a) => !a.startsWith('--')); + const fastMode = args.includes('--fast'); + const targets = args.filter(a => !a.startsWith('--')); - if (helpMode) { - printUsage(); - process.exit(0); - } + if (helpMode) { printUsage(); process.exit(0); } let allFindings = []; - // Check if stdin is piped if (!process.stdin.isTTY && targets.length === 0) { allFindings = await handleStdin(); } else { - // Default to cwd if no targets const paths = targets.length > 0 ? targets : [process.cwd()]; for (const target of paths) { - // URL → Puppeteer - if (isUrl(target)) { - try { - const findings = await detectAntiPatternsUrl(target); - allFindings.push(...findings); - } catch (e) { - process.stderr.write(`Error scanning URL ${target}: ${e.message}\n`); - } + if (/^https?:\/\//i.test(target)) { + try { allFindings.push(...await detectUrl(target)); } + catch (e) { process.stderr.write(`Error: ${e.message}\n`); } continue; } const resolved = path.resolve(target); let stat; - try { - stat = fs.statSync(resolved); - } catch { - process.stderr.write(`Warning: cannot access ${target}\n`); - continue; - } + try { stat = fs.statSync(resolved); } + catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } if (stat.isDirectory()) { for (const file of walkDir(resolved)) { - if (deepMode && file.endsWith('.html')) { - allFindings.push(...await detectAntiPatternsDeep(file)); + const ext = path.extname(file).toLowerCase(); + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(file)); } else { - const content = fs.readFileSync(file, 'utf-8'); - allFindings.push(...detectAntiPatterns(content, file)); + allFindings.push(...detectText(fs.readFileSync(file, 'utf-8'), file)); } } } else if (stat.isFile()) { - if (deepMode && resolved.endsWith('.html')) { - allFindings.push(...await detectAntiPatternsDeep(resolved)); + const ext = path.extname(resolved).toLowerCase(); + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(resolved)); } else { - const content = fs.readFileSync(resolved, 'utf-8'); - allFindings.push(...detectAntiPatterns(content, resolved)); + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved)); } } } } if (allFindings.length > 0) { - const output = formatFindings(allFindings, jsonMode); - process.stderr.write(output + '\n'); + process.stderr.write(formatFindings(allFindings, jsonMode) + '\n'); process.exit(2); } - - if (jsonMode) { - process.stdout.write('[]\n'); - } + if (jsonMode) process.stdout.write('[]\n'); process.exit(0); } -// Run CLI when executed directly; export for testing when imported -const isMainModule = process.argv[1] && ( - process.argv[1].endsWith('detect-antipatterns.mjs') || - process.argv[1].endsWith('detect-antipatterns.mjs/') -); +// --------------------------------------------------------------------------- +// Entry point + exports +// --------------------------------------------------------------------------- -if (isMainModule) { - main(); -} +const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs'); +if (isMainModule) main(); -export { ANTIPATTERNS, detectAntiPatterns, detectAntiPatternsDeep, detectAntiPatternsUrl, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS }; +export { + ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, + checkElementBorders, checkPageTypography, isNeutralColor, + detectHtml, detectUrl, detectText, + walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS, +}; diff --git a/public/js/detect-antipatterns-browser.js b/public/js/detect-antipatterns-browser.js index 8b30fda2e..54a5d1ecd 100644 --- a/public/js/detect-antipatterns-browser.js +++ b/public/js/detect-antipatterns-browser.js @@ -2,17 +2,10 @@ * Anti-Pattern Browser Detector for Impeccable * * Drop this script into any page to visually highlight UI anti-patterns. + * Uses getComputedStyle() and document.styleSheets for accurate detection. * - * Two detection modes: - * - "static" (default): regex on HTML source — same logic as the CLI script, - * so fixture pages test exactly what the CLI tests. - * - "computed": getComputedStyle() — catches CSS cascade, inherited styles. - * More accurate but may diverge from CLI results. - * - * Set mode via data attribute on the script tag: - * - * - * Or call: window.impeccableScan({ mode: 'computed' }) + * Usage: + * Re-scan: window.impeccableScan() */ (function () { if (typeof window === 'undefined') return; @@ -20,290 +13,129 @@ const LABEL_BG = 'oklch(55% 0.25 350)'; const OUTLINE_COLOR = 'oklch(60% 0.25 350)'; - // Read mode from script tag data attribute (default: static) - const scriptTag = document.currentScript; - const defaultMode = scriptTag?.dataset?.mode || 'static'; + const SAFE_TAGS = new Set([ + 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', + 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', + 'button', 'hr', 'html', 'head', 'body', 'script', 'style', + 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', + 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', + ]); + + const OVERUSED_FONTS = new Set([ + 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', + ]); + + const GENERIC_FONTS = new Set([ + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', + 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', + '-apple-system', 'blinkmacsystemfont', 'segoe ui', + 'inherit', 'initial', 'unset', 'revert', + ]); // ----------------------------------------------------------------------- - // Static detection (mirrors CLI regex logic) + // Detection (computed styles) // ----------------------------------------------------------------------- - const SAFE_ELEMENTS_RE = /^(blockquote|nav|a|input|textarea|select|pre|code|span|th|td|tr|li|label|button|hr)$/i; - - function hasRoundedClass(str) { return /\brounded(?:-\w+)?\b/.test(str); } - - - /** - * Scan `); + } catch { /* skip unreadable */ } + } + } + + const dom = new JSDOM(processedHtml, { + url: `file://${resolvedPath}`, + pretendToBeVisual: true, + }); + const { window } = dom; + const { document } = window; + + await new Promise(r => setTimeout(r, 50)); + + const findings = []; + + // Element-level border checks + for (const el of document.querySelectorAll('*')) { + const tag = el.tagName.toLowerCase(); + const style = window.getComputedStyle(el); + for (const f of checkElementBorders(tag, style)) { + findings.push(finding(f.id, filePath, f.snippet)); + } + } + + // Page-level typography checks + for (const f of checkPageTypography(document, window)) { + findings.push(finding(f.id, filePath, f.snippet)); + } + + window.close(); + return findings; +} + +// --------------------------------------------------------------------------- +// Puppeteer detection (for URLs) +// --------------------------------------------------------------------------- + +async function detectUrl(url) { + let puppeteer; + try { + puppeteer = await import('puppeteer'); + } catch { + throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer'); + } + + const browser = await puppeteer.default.launch({ headless: true }); + const page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 800 }); + await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); + + // Serialize shared functions for page.evaluate + const safeTags = [...SAFE_TAGS]; + const overusedFonts = [...OVERUSED_FONTS]; + const genericFonts = [...GENERIC_FONTS]; + + const results = await page.evaluate((safeTags, overusedFonts, genericFonts) => { + const safe = new Set(safeTags); + const overused = new Set(overusedFonts); + const generic = new Set(genericFonts); + const findings = []; + const sides = ['Top', 'Right', 'Bottom', 'Left']; + + // Element-level border checks + for (const el of document.querySelectorAll('*')) { + const tag = el.tagName.toLowerCase(); + if (safe.has(tag)) continue; + const rect = el.getBoundingClientRect(); + if (rect.width < 20 || rect.height < 20) continue; + + const style = getComputedStyle(el); + const widths = {}, colors = {}; + for (const s of sides) { + widths[s] = parseFloat(style[`border${s}Width`]) || 0; + colors[s] = style[`border${s}Color`] || ''; + } + const radius = parseFloat(style.borderRadius) || 0; + + for (const side of sides) { + const w = widths[side]; + if (w < 1) continue; + const c = colors[side]; + if (!c || c === 'transparent') continue; + const cm = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (cm && (Math.max(+cm[1], +cm[2], +cm[3]) - Math.min(+cm[1], +cm[2], +cm[3])) < 30) continue; + + const others = sides.filter(s => s !== side); + const maxOther = Math.max(...others.map(s => widths[s])); + if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue; + + const sn = side.toLowerCase(); + const isSide = side === 'Left' || side === 'Right'; + if (isSide) { + if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); + else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` }); + } else { + if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` }); + } + } + } + + // Typography checks + const fonts = new Set(); + const overusedFound = new Set(); + for (const sheet of document.styleSheets) { + let rules; + try { rules = sheet.cssRules; } catch { continue; } + if (!rules) continue; + for (const rule of rules) { + if (rule.type !== 1) continue; + const ff = rule.style?.fontFamily; + if (!ff) continue; + const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); + const primary = stack.find(f => f && !generic.has(f)); + if (primary) { + fonts.add(primary); + if (overused.has(primary)) overusedFound.add(primary); + } + } + } + for (const f of overusedFound) findings.push({ id: 'overused-font', snippet: `Primary font: ${f}` }); + if (fonts.size === 1 && document.querySelectorAll('*').length > 20) { + findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` }); + } + + const sizes = new Set(); + for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) { + const fs = parseFloat(getComputedStyle(el).fontSize); + if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10); + } + if (sizes.size >= 3) { + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio < 2.0) findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` }); + } + + return findings; + }, safeTags, overusedFonts, genericFonts); + + await browser.close(); + return results.map(f => finding(f.id, url, f.snippet)); +} + +// --------------------------------------------------------------------------- +// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.) // --------------------------------------------------------------------------- /** Check if Tailwind `rounded-*` appears on the same line */ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line); - -/** Check if CSS `border-radius` appears on the same line (inline styles) */ const hasBorderRadius = (line) => /border-radius/i.test(line); +const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line); -/** Check if line contains an HTML element that legitimately uses side borders */ -const SAFE_ELEMENTS = /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i; -const isSafeElement = (line) => SAFE_ELEMENTS.test(line); - -/** Check if the border color in a CSS declaration looks neutral (gray/structural) */ -function isNeutralBorderColor(matchStr) { - const colorMatch = matchStr.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); - if (!colorMatch) return false; - const c = colorMatch[1].toLowerCase(); +function isNeutralBorderColor(str) { + const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i); + if (!m) return false; + const c = m[1].toLowerCase(); if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true; const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/); if (hex) { @@ -51,553 +434,119 @@ function isNeutralBorderColor(matchStr) { return false; } -// --------------------------------------------------------------------------- -// Anti-pattern definitions -// --------------------------------------------------------------------------- +const REGEX_MATCHERS = [ + // --- Side-tab --- + { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, + test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; }, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, + test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; }, + fmt: (m) => m[0].replace(/\s*;?\s*$/, '') }, + { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, + test: (m, line) => !isSafeElement(line) && +m[1] >= 3, + fmt: (m) => m[0] }, + { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, + test: (m) => +m[1] >= 3, + fmt: (m) => m[0] }, + // --- Border accent on rounded --- + { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g, + test: (m, line) => hasRounded(line) && +m[1] >= 1, + fmt: (m) => m[0] }, + { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, + test: (m, line) => +m[1] >= 3 && hasBorderRadius(line), + fmt: (m) => m[0] }, + // --- Overused font --- + { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi, + test: () => true, + fmt: (m) => m[0] }, + { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi, + test: () => true, + fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` }, +]; -const ANTIPATTERNS = [ - { - id: 'side-tab', - name: 'Side-tab accent border', - description: - 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', - matchers: [ - // Tailwind: border-[lrse]-N — threshold depends on context - // With rounded: any N >= 1 (even thin borders look wrong on rounded cards) - // Without rounded: N >= 4 (thick enough to always be suspicious) - { - regex: /\bborder-[lrse]-(\d+)\b/g, - test: (match, line) => { - const n = parseInt(match[1], 10); - if (hasRounded(line)) return n >= 1; - return n >= 4; - }, - format: (match) => match[0], - }, - // CSS shorthand: border-left/right: Npx solid [color] - { - regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - if (isNeutralBorderColor(match[0])) return false; - const n = parseInt(match[1], 10); - if (hasBorderRadius(line)) return n >= 1; - return n >= 3; - }, - format: (match) => match[0].replace(/\s*;?\s*$/, ''), - }, - // CSS longhand: border-left/right-width: Npx - { - regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // CSS logical: border-inline-start/end: Npx solid - { - regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // CSS logical longhand: border-inline-start/end-width: Npx - { - regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi, - test: (match, line) => { - if (isSafeElement(line)) return false; - const n = parseInt(match[1], 10); - return n >= 3; - }, - format: (match) => match[0], - }, - // JSX inline: borderLeft/borderRight with thickness - { - regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g, - test: (match) => parseInt(match[1], 10) >= 3, - format: (match) => match[0], - }, - ], +const REGEX_ANALYZERS = [ + // Single font + (content, filePath) => { + const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; + const fonts = new Set(); + let m; + while ((m = fontFamilyRe.exec(content)) !== null) { + for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) { + if (f && !GENERIC_FONTS.has(f)) fonts.add(f); + } + } + const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; + while ((m = gfRe.exec(content)) !== null) { + for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f); + } + if (fonts.size !== 1 || content.split('\n').length < 20) return []; + const name = [...fonts][0]; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } } + return [finding('single-font', filePath, `Only font: ${name}`, line)]; }, - { - id: 'border-accent-on-rounded', - name: 'Border accent on rounded element', - description: - 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', - matchers: [ - // Tailwind: border-[tb]-N + rounded-* on same line - { - regex: /\bborder-[tb]-(\d+)\b/g, - test: (match, line) => { - const n = parseInt(match[1], 10); - return hasRounded(line) && n >= 1; - }, - format: (match) => match[0], - }, - // CSS: border-top/bottom with border-radius on same line (inline styles) - { - regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi, - test: (match, line) => { - const n = parseInt(match[1], 10); - return n >= 3 && hasBorderRadius(line); - }, - format: (match) => match[0], - }, - ], - }, - // ------------------------------------------------------------------------- - // Typography anti-patterns - // ------------------------------------------------------------------------- - { - id: 'overused-font', - name: 'Overused font', - description: - 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', - matchers: [ - // CSS font-family: 'Inter' as primary (first) font - { - regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi, - test: () => true, - format: (match) => match[0], - }, - // Google Fonts import/link - { - regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi, - test: () => true, - format: (match) => `Google Fonts: ${match[1].replace(/\+/g, ' ')}`, - }, - ], - }, - { - id: 'single-font', - name: 'Single font for everything', - description: - 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', - analyzers: [ - (content, filePath) => { - // Extract all font names from font-family declarations - const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi; - const GENERIC = new Set([ - 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', - 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', - '-apple-system', 'blinkmacsystemfont', 'segoe ui', 'inherit', 'initial', 'unset', - ]); - const fonts = new Set(); - let m; - while ((m = fontFamilyRe.exec(content)) !== null) { - // Extract individual font names from the stack - const stack = m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()); - for (const f of stack) { - if (f && !GENERIC.has(f)) fonts.add(f); - } - } - - // Also extract from Google Fonts imports - const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi; - while ((m = gfRe.exec(content)) !== null) { - const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase()); - for (const f of families) fonts.add(f); - } - - // Only flag if the file has meaningful content and exactly 1 font - if (fonts.size !== 1) return []; - // Don't flag tiny files (likely components) - const lineCount = content.split('\n').length; - if (lineCount < 20) return []; - - const fontName = [...fonts][0]; - // Find the first line where this font appears for reporting - const lines = content.split('\n'); - let reportLine = 1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].toLowerCase().includes(fontName)) { - reportLine = i + 1; - break; - } - } - - return [{ - antipattern: 'single-font', - name: 'Single font for everything', - description: 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', - file: filePath, - line: reportLine, - snippet: `Only font: ${fontName}`, - }]; - }, - ], - }, - { - id: 'flat-type-hierarchy', - name: 'Flat type hierarchy', - description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', - analyzers: [ - (content, filePath) => { - // Collect all font-size values and convert to px - const sizes = new Set(); - const REM_BASE = 16; - const lines = content.split('\n'); - - // CSS font-size declarations - const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; - let m; - while ((m = sizeRe.exec(content)) !== null) { - const val = parseFloat(m[1]); - const unit = m[2].toLowerCase(); - const px = unit === 'px' ? val : val * REM_BASE; - if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); - } - - // clamp() — extract min and max - const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; - while ((m = clampRe.exec(content)) !== null) { - const minVal = parseFloat(m[1]); - const minUnit = m[2].toLowerCase(); - const maxVal = parseFloat(m[3]); - const maxUnit = m[4].toLowerCase(); - sizes.add(Math.round((minUnit === 'px' ? minVal : minVal * REM_BASE) * 10) / 10); - sizes.add(Math.round((maxUnit === 'px' ? maxVal : maxVal * REM_BASE) * 10) / 10); - } - - // Tailwind text-* classes → approximate px values - const TW_SIZES = { - 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, - 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, - 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128, - }; - for (const [cls, px] of Object.entries(TW_SIZES)) { - const twRe = new RegExp(`\\b${cls}\\b`); - if (twRe.test(content)) sizes.add(px); - } - - // Need at least 3 distinct sizes to evaluate hierarchy - if (sizes.size < 3) return []; - - const sorted = [...sizes].sort((a, b) => a - b); - const ratio = sorted[sorted.length - 1] / sorted[0]; - - // A healthy hierarchy has at least 2x range (e.g., 14px body to 36px heading) - if (ratio >= 2.0) return []; - - // Find line to report on (first font-size declaration) - let reportLine = 1; - for (let i = 0; i < lines.length; i++) { - if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { - reportLine = i + 1; - break; - } - } - - return [{ - antipattern: 'flat-type-hierarchy', - name: 'Flat type hierarchy', - description: 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', - file: filePath, - line: reportLine, - snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, - }]; - }, - ], + // Flat type hierarchy + (content, filePath) => { + const sizes = new Set(); + const REM = 16; + let m; + const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi; + while ((m = sizeRe.exec(content)) !== null) { + const px = m[2] === 'px' ? +m[1] : +m[1] * REM; + if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10); + } + const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi; + while ((m = clampRe.exec(content)) !== null) { + sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10); + sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10); + } + const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 }; + for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); } + if (sizes.size < 3) return []; + const sorted = [...sizes].sort((a, b) => a - b); + const ratio = sorted[sorted.length - 1] / sorted[0]; + if (ratio >= 2.0) return []; + const lines = content.split('\n'); + let line = 1; + for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } } + return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)]; }, ]; -// --------------------------------------------------------------------------- -// Detection engine -// --------------------------------------------------------------------------- - /** - * Scan content for anti-patterns. - * @param {string} content File content - * @param {string} filePath File path (for reporting) - * @returns {Array<{antipattern: string, name: string, description: string, file: string, line: number, snippet: string}>} + * Regex-based detection for non-HTML files or --fast mode. */ -function detectAntiPatterns(content, filePath) { +function detectText(content, filePath) { const findings = []; const lines = content.split('\n'); - for (const ap of ANTIPATTERNS) { - // Line-level matchers - if (ap.matchers) { - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - for (const matcher of ap.matchers) { - // Reset regex state for each line - matcher.regex.lastIndex = 0; - let m; - while ((m = matcher.regex.exec(line)) !== null) { - if (matcher.test(m, line)) { - findings.push({ - antipattern: ap.id, - name: ap.name, - description: ap.description, - file: filePath, - line: i + 1, - snippet: matcher.format(m), - }); - } - } + for (const matcher of REGEX_MATCHERS) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + matcher.regex.lastIndex = 0; + let m; + while ((m = matcher.regex.exec(line)) !== null) { + if (matcher.test(m, line)) { + findings.push(finding(matcher.id, filePath, matcher.fmt(m), i + 1)); } } } + } - // File-level analyzers - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - findings.push(...analyzer(content, filePath)); - } - } + for (const analyzer of REGEX_ANALYZERS) { + findings.push(...analyzer(content, filePath)); } return findings; } -// --------------------------------------------------------------------------- -// Deep detection (jsdom / Puppeteer — computed styles) -// --------------------------------------------------------------------------- - -const SAFE_TAGS_DEEP = new Set([ - 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', - 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', - 'button', 'hr', 'html', 'head', 'body', 'script', 'style', - 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', -]); - -/** - * Analyze a single DOM element using computed styles. - * Works with both jsdom window and Puppeteer page. - */ -function analyzeElementDeep(el, computedStyle, filePath) { - const findings = []; - const tag = el.tagName.toLowerCase(); - if (SAFE_TAGS_DEEP.has(tag)) return findings; - - const sides = ['Top', 'Right', 'Bottom', 'Left']; - const widths = {}; - const colors = {}; - for (const s of sides) { - widths[s] = parseFloat(computedStyle[`border${s}Width`]) || 0; - colors[s] = computedStyle[`border${s}Color`] || ''; - } - const radius = parseFloat(computedStyle.borderRadius) || 0; - const fontSize = parseFloat(computedStyle.fontSize) || 0; - const fontFamily = computedStyle.fontFamily || ''; - - // --- Border accent detection --- - for (const side of sides) { - const w = widths[side]; - if (w < 1) continue; - - // Check if border color is transparent - const color = colors[side]; - if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue; - - // Check if neutral (gray) - const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbMatch) { - const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]]; - if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue; - } - - const otherSides = sides.filter(s => s !== side); - const maxOther = Math.max(...otherSides.map(s => widths[s])); - const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2); - if (!isAccent) continue; - - const isSide = side === 'Left' || side === 'Right'; - const sideName = side.toLowerCase(); - - if (isSide) { - if (radius > 0) { - findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } else if (w >= 3) { - findings.push({ antipattern: 'side-tab', name: 'Side-tab accent border', description: ANTIPATTERNS[0].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px` }); - } - } else { - if (radius > 0 && w >= 2) { - findings.push({ antipattern: 'border-accent-on-rounded', name: 'Border accent on rounded element', description: ANTIPATTERNS[1].description, file: filePath, line: 0, snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } - } - } - - return findings; -} - -/** - * Deep scan using jsdom — resolves linked stylesheets, computes styles. - * @param {string} filePath Path to an HTML file - * @returns {Promise} findings - */ -async function detectAntiPatternsDeep(filePath) { - let JSDOM; - try { - ({ JSDOM } = await import('jsdom')); - } catch { - throw new Error('jsdom is required for --deep mode. Install it: npm install jsdom'); - } - - const html = fs.readFileSync(filePath, 'utf-8'); - const resolvedPath = path.resolve(filePath); - const fileDir = path.dirname(resolvedPath); - - // Resolve linked stylesheets and inline them - let processedHtml = html; - const linkRe = /]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi; - const linkRe2 = /]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi; - for (const re of [linkRe, linkRe2]) { - let m; - while ((m = re.exec(html)) !== null) { - const href = m[1]; - // Only resolve local files, not URLs - if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) continue; - const cssPath = path.resolve(fileDir, href); - try { - const cssContent = fs.readFileSync(cssPath, 'utf-8'); - processedHtml = processedHtml.replace(m[0], ``); - } catch { - // Can't read stylesheet, skip - } - } - } - - const dom = new JSDOM(processedHtml, { - url: `file://${resolvedPath}`, - resources: 'usable', - pretendToBeVisual: true, - }); - - const { window } = dom; - const { document } = window; - - // Wait for styles to apply - await new Promise(r => setTimeout(r, 100)); - - const findings = []; - const elements = document.querySelectorAll('*'); - - for (const el of elements) { - const style = window.getComputedStyle(el); - findings.push(...analyzeElementDeep(el, style, filePath)); - } - - // Also run file-level analyzers (overused fonts, single font, flat hierarchy) - // These work on the raw content which is fine - for (const ap of ANTIPATTERNS) { - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - findings.push(...analyzer(html, filePath)); - } - } - } - - window.close(); - return findings; -} - -/** - * Deep scan using Puppeteer — full browser rendering for URLs. - * @param {string} url URL to scan - * @returns {Promise} findings - */ -async function detectAntiPatternsUrl(url) { - let puppeteer; - try { - puppeteer = await import('puppeteer'); - } catch { - throw new Error('puppeteer is required for URL scanning. Install it: npm install puppeteer'); - } - - const browser = await puppeteer.default.launch({ headless: true }); - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 }); - - // Run detection in the browser context - const findings = await page.evaluate((safeTags) => { - const results = []; - const safe = new Set(safeTags); - const sides = ['Top', 'Right', 'Bottom', 'Left']; - const elements = document.querySelectorAll('*'); - - for (const el of elements) { - const tag = el.tagName.toLowerCase(); - if (safe.has(tag)) continue; - const rect = el.getBoundingClientRect(); - if (rect.width < 20 || rect.height < 20) continue; - - const style = getComputedStyle(el); - const widths = {}; - const colors = {}; - for (const s of sides) { - widths[s] = parseFloat(style[`border${s}Width`]) || 0; - colors[s] = style[`border${s}Color`] || ''; - } - const radius = parseFloat(style.borderRadius) || 0; - - for (const side of sides) { - const w = widths[side]; - if (w < 1) continue; - const color = colors[side]; - if (!color || color === 'transparent' || /rgba\([^)]*,\s*0\)/.test(color)) continue; - const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (rgbMatch) { - const [r, g, b] = [+rgbMatch[1], +rgbMatch[2], +rgbMatch[3]]; - if (Math.max(r, g, b) - Math.min(r, g, b) < 30) continue; - } - - const otherSides = sides.filter(s => s !== side); - const maxOther = Math.max(...otherSides.map(s => widths[s])); - const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2); - if (!isAccent) continue; - - const isSide = side === 'Left' || side === 'Right'; - const sideName = side.toLowerCase(); - - if (isSide) { - if (radius > 0) { - results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } else if (w >= 3) { - results.push({ antipattern: 'side-tab', snippet: `border-${sideName}: ${w}px` }); - } - } else { - if (radius > 0 && w >= 2) { - results.push({ antipattern: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` }); - } - } - } - } - - return results; - }, [...SAFE_TAGS_DEEP]); - - // Enrich findings with metadata - const enriched = findings.map(f => ({ - ...f, - name: f.antipattern === 'side-tab' ? 'Side-tab accent border' : 'Border accent on rounded element', - description: ANTIPATTERNS.find(a => a.id === f.antipattern)?.description || '', - file: url, - line: 0, - })); - - // Also get the page HTML for file-level analyzers - const html = await page.content(); - for (const ap of ANTIPATTERNS) { - if (ap.analyzers) { - for (const analyzer of ap.analyzers) { - enriched.push(...analyzer(html, url)); - } - } - } - - await browser.close(); - return enriched; -} - // --------------------------------------------------------------------------- // File walker // --------------------------------------------------------------------------- @@ -613,23 +562,17 @@ const SCANNABLE_EXTENSIONS = new Set([ '.vue', '.svelte', '.astro', ]); +const HTML_EXTENSIONS = new Set(['.html', '.htm']); + function walkDir(dir) { const files = []; let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return files; - } + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; } for (const entry of entries) { - if (entry.name.startsWith('.') && SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue; const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...walkDir(full)); - } else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { - files.push(full); - } + if (entry.isDirectory()) files.push(...walkDir(full)); + else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full); } return files; } @@ -639,61 +582,42 @@ function walkDir(dir) { // --------------------------------------------------------------------------- function formatFindings(findings, jsonMode) { - if (jsonMode) { - return JSON.stringify(findings, null, 2); - } + if (jsonMode) return JSON.stringify(findings, null, 2); const grouped = {}; for (const f of findings) { if (!grouped[f.file]) grouped[f.file] = []; grouped[f.file].push(f); } - - const lines = []; + const out = []; for (const [file, items] of Object.entries(grouped)) { - lines.push(`\n${file}`); + out.push(`\n${file}`); for (const item of items) { - lines.push(` line ${item.line}: [${item.antipattern}] ${item.snippet}`); - lines.push(` → ${item.description}`); + out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); + out.push(` → ${item.description}`); } } - - const count = findings.length; - lines.push(`\n${count} anti-pattern${count === 1 ? '' : 's'} found.`); - return lines.join('\n'); + out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`); + return out.join('\n'); } // --------------------------------------------------------------------------- -// Stdin handling (for future hook use) +// Stdin handling // --------------------------------------------------------------------------- -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - return Buffer.concat(chunks).toString('utf-8'); -} - async function handleStdin() { - const input = await readStdin(); - let parsed; + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const input = Buffer.concat(chunks).toString('utf-8'); try { - parsed = JSON.parse(input); - } catch { - // Not JSON — treat as raw content - return detectAntiPatterns(input, ''); - } - - // Hook format: { tool_input: { file_path: "..." } } - const filePath = parsed?.tool_input?.file_path; - if (filePath && fs.existsSync(filePath)) { - const content = fs.readFileSync(filePath, 'utf-8'); - return detectAntiPatterns(content, filePath); - } - - // Fallback: scan the raw JSON as content - return detectAntiPatterns(input, ''); + const parsed = JSON.parse(input); + const fp = parsed?.tool_input?.file_path; + if (fp && fs.existsSync(fp)) { + return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) + ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp); + } + } catch { /* not JSON */ } + return detectText(input, ''); } // --------------------------------------------------------------------------- @@ -706,108 +630,89 @@ function printUsage() { Scan files or URLs for known UI anti-patterns. Options: - --deep Use jsdom for computed style analysis (catches linked stylesheets) + --fast Regex-only mode (skip jsdom, faster but misses linked stylesheets) --json Output results as JSON --help Show this help message -Modes: - file/dir Fast regex scan (default) - file + --deep jsdom computed styles (resolves local CSS) - https://... Puppeteer full browser (auto, resolves everything) +Detection modes: + HTML files jsdom with computed styles (default, catches linked CSS) + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) + URLs Puppeteer full browser rendering (auto-detected) + --fast Forces regex for all files Examples: node detect-antipatterns.mjs src/ - node detect-antipatterns.mjs --deep index.html + node detect-antipatterns.mjs index.html node detect-antipatterns.mjs https://example.com - node detect-antipatterns.mjs --json .`); -} - -function isUrl(str) { - return /^https?:\/\//i.test(str); + node detect-antipatterns.mjs --fast --json .`); } async function main() { const args = process.argv.slice(2); const jsonMode = args.includes('--json'); const helpMode = args.includes('--help'); - const deepMode = args.includes('--deep'); - const targets = args.filter((a) => !a.startsWith('--')); + const fastMode = args.includes('--fast'); + const targets = args.filter(a => !a.startsWith('--')); - if (helpMode) { - printUsage(); - process.exit(0); - } + if (helpMode) { printUsage(); process.exit(0); } let allFindings = []; - // Check if stdin is piped if (!process.stdin.isTTY && targets.length === 0) { allFindings = await handleStdin(); } else { - // Default to cwd if no targets const paths = targets.length > 0 ? targets : [process.cwd()]; for (const target of paths) { - // URL → Puppeteer - if (isUrl(target)) { - try { - const findings = await detectAntiPatternsUrl(target); - allFindings.push(...findings); - } catch (e) { - process.stderr.write(`Error scanning URL ${target}: ${e.message}\n`); - } + if (/^https?:\/\//i.test(target)) { + try { allFindings.push(...await detectUrl(target)); } + catch (e) { process.stderr.write(`Error: ${e.message}\n`); } continue; } const resolved = path.resolve(target); let stat; - try { - stat = fs.statSync(resolved); - } catch { - process.stderr.write(`Warning: cannot access ${target}\n`); - continue; - } + try { stat = fs.statSync(resolved); } + catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } if (stat.isDirectory()) { for (const file of walkDir(resolved)) { - if (deepMode && file.endsWith('.html')) { - allFindings.push(...await detectAntiPatternsDeep(file)); + const ext = path.extname(file).toLowerCase(); + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(file)); } else { - const content = fs.readFileSync(file, 'utf-8'); - allFindings.push(...detectAntiPatterns(content, file)); + allFindings.push(...detectText(fs.readFileSync(file, 'utf-8'), file)); } } } else if (stat.isFile()) { - if (deepMode && resolved.endsWith('.html')) { - allFindings.push(...await detectAntiPatternsDeep(resolved)); + const ext = path.extname(resolved).toLowerCase(); + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(resolved)); } else { - const content = fs.readFileSync(resolved, 'utf-8'); - allFindings.push(...detectAntiPatterns(content, resolved)); + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved)); } } } } if (allFindings.length > 0) { - const output = formatFindings(allFindings, jsonMode); - process.stderr.write(output + '\n'); + process.stderr.write(formatFindings(allFindings, jsonMode) + '\n'); process.exit(2); } - - if (jsonMode) { - process.stdout.write('[]\n'); - } + if (jsonMode) process.stdout.write('[]\n'); process.exit(0); } -// Run CLI when executed directly; export for testing when imported -const isMainModule = process.argv[1] && ( - process.argv[1].endsWith('detect-antipatterns.mjs') || - process.argv[1].endsWith('detect-antipatterns.mjs/') -); +// --------------------------------------------------------------------------- +// Entry point + exports +// --------------------------------------------------------------------------- -if (isMainModule) { - main(); -} +const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs'); +if (isMainModule) main(); -export { ANTIPATTERNS, detectAntiPatterns, detectAntiPatternsDeep, detectAntiPatternsUrl, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS }; +export { + ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, + checkElementBorders, checkPageTypography, isNeutralColor, + detectHtml, detectUrl, detectText, + walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS, +}; diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index 30a978d48..c55cafbfa 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -2,506 +2,213 @@ import { describe, test, expect } from 'bun:test'; import fs from 'fs'; import path from 'path'; import { spawnSync } from 'child_process'; -import { detectAntiPatterns, ANTIPATTERNS, walkDir, SCANNABLE_EXTENSIONS } from '../source/skills/critique/scripts/detect-antipatterns.mjs'; +import { + ANTIPATTERNS, checkElementBorders, isNeutralColor, + detectHtml, detectText, + walkDir, SCANNABLE_EXTENSIONS, +} from '../source/skills/critique/scripts/detect-antipatterns.mjs'; const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns'); const SCRIPT = path.join(import.meta.dir, '..', 'source', 'skills', 'critique', 'scripts', 'detect-antipatterns.mjs'); // --------------------------------------------------------------------------- -// Core detection: Tailwind side-tab +// Core: checkElementBorders (computed style simulation) // --------------------------------------------------------------------------- -describe('detectAntiPatterns — Tailwind side-tab', () => { - test('detects border-l-4 (always, thick enough)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].antipattern).toBe('side-tab'); - expect(findings[0].snippet).toBe('border-l-4'); +describe('checkElementBorders', () => { + function mockStyle(overrides) { + return { borderTopWidth: '0', borderRightWidth: '0', borderBottomWidth: '0', borderLeftWidth: '0', + borderTopColor: '', borderRightColor: '', borderBottomColor: '', borderLeftColor: '', + borderRadius: '0', ...overrides }; + } + + test('detects side-tab with radius', () => { + const f = checkElementBorders('div', mockStyle({ + borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)', borderRadius: '12', + })); + expect(f.length).toBe(1); + expect(f[0].id).toBe('side-tab'); }); - test('detects border-e-8 (always, thick enough)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toBe('border-e-8'); + test('detects side-tab without radius (thick)', () => { + const f = checkElementBorders('div', mockStyle({ + borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)', + })); + expect(f.length).toBe(1); + expect(f[0].id).toBe('side-tab'); }); - test('ignores border-r-2 without rounded (below threshold)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); + test('skips side border below threshold without radius', () => { + const f = checkElementBorders('div', mockStyle({ + borderLeftWidth: '2', borderLeftColor: 'rgb(59, 130, 246)', + })); + expect(f).toHaveLength(0); }); - test('detects border-r-2 WITH rounded (context-aware)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toBe('border-r-2'); + test('detects border-accent-on-rounded (top)', () => { + const f = checkElementBorders('div', mockStyle({ + borderTopWidth: '3', borderTopColor: 'rgb(139, 92, 246)', borderRadius: '12', + })); + expect(f.length).toBe(1); + expect(f[0].id).toBe('border-accent-on-rounded'); }); - test('detects border-l-1 with rounded (even thin borders)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); + test('skips safe tags', () => { + const f = checkElementBorders('blockquote', mockStyle({ + borderLeftWidth: '4', borderLeftColor: 'rgb(59, 130, 246)', + })); + expect(f).toHaveLength(0); + }); + + test('skips neutral colors', () => { + const f = checkElementBorders('div', mockStyle({ + borderLeftWidth: '4', borderLeftColor: 'rgb(200, 200, 200)', + })); + expect(f).toHaveLength(0); + }); + + test('skips uniform borders (not accent)', () => { + const f = checkElementBorders('div', mockStyle({ + borderTopWidth: '2', borderRightWidth: '2', borderBottomWidth: '2', borderLeftWidth: '2', + borderTopColor: 'rgb(59, 130, 246)', borderRightColor: 'rgb(59, 130, 246)', + borderBottomColor: 'rgb(59, 130, 246)', borderLeftColor: 'rgb(59, 130, 246)', + })); + expect(f).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// isNeutralColor +// --------------------------------------------------------------------------- + +describe('isNeutralColor', () => { + test('gray is neutral', () => expect(isNeutralColor('rgb(200, 200, 200)')).toBe(true)); + test('blue is not neutral', () => expect(isNeutralColor('rgb(59, 130, 246)')).toBe(false)); + test('transparent is neutral', () => expect(isNeutralColor('transparent')).toBe(true)); + test('null is neutral', () => expect(isNeutralColor(null)).toBe(true)); +}); + +// --------------------------------------------------------------------------- +// Regex fallback (detectText) +// --------------------------------------------------------------------------- + +describe('detectText — Tailwind side-tab', () => { + test('detects border-l-4 (thick, no rounded needed)', () => { + const f = detectText('
', 'test.html'); + expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); + }); + + test('detects border-l-1 + rounded', () => { + const f = detectText('
', 'test.html'); + expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); }); test('ignores border-l-1 without rounded', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); + const f = detectText('
', 'test.html'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); }); - test('ignores border-l-0', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('detects multiple on same line', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(2); - }); - - test('does not flag border-t or border-b without rounded', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); + test('ignores border-t without rounded', () => { + const f = detectText('
', 'test.html'); + expect(f.filter(r => r.antipattern === 'border-accent-on-rounded')).toHaveLength(0); }); }); -// --------------------------------------------------------------------------- -// Context-aware detection: rounded corners -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — rounded context', () => { - test('border-s-2 + rounded-xl triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); +describe('detectText — CSS borders', () => { + test('detects border-left shorthand', () => { + const f = detectText('.card { border-left: 4px solid #3b82f6; }', 'test.css'); + expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); }); - test('border-l-3 + rounded triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); + test('ignores neutral border', () => { + const f = detectText('.card { border-left: 4px solid #e5e7eb; }', 'test.css'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); }); - test('border-l-3 without rounded does not trigger', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Safe element exclusions -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — safe elements', () => { test('skips blockquote', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('skips nav link', () => { - const findings = detectAntiPatterns('', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('skips input', () => { - const findings = detectAntiPatterns('', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('skips code/pre', () => { - const findings = detectAntiPatterns('', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('skips span (code diff lines)', () => { - const findings = detectAntiPatterns('', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('does NOT skip div (still flags)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); + const f = detectText('
', 'test.html'); + expect(f.filter(r => r.antipattern === 'side-tab')).toHaveLength(0); }); }); -// --------------------------------------------------------------------------- -// Core detection: CSS shorthand -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — CSS shorthand', () => { - test('detects border-left: Npx solid', () => { - const findings = detectAntiPatterns('.card { border-left: 4px solid #3b82f6; }', 'test.css'); - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toContain('border-left'); - }); - - test('detects border-right: Npx solid', () => { - const findings = detectAntiPatterns('.card { border-right: 5px solid purple; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('ignores border-left: 2px solid (below threshold)', () => { - const findings = detectAntiPatterns('.card { border-left: 2px solid blue; }', 'test.css'); - expect(findings).toHaveLength(0); - }); - - test('ignores border-top: 4px solid', () => { - const findings = detectAntiPatterns('.card { border-top: 4px solid blue; }', 'test.css'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Core detection: CSS longhand -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — CSS longhand', () => { - test('detects border-left-width: Npx', () => { - const findings = detectAntiPatterns('.card { border-left-width: 3px; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects border-right-width: Npx', () => { - const findings = detectAntiPatterns('.card { border-right-width: 6px; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('ignores border-left-width: 2px', () => { - const findings = detectAntiPatterns('.card { border-left-width: 2px; }', 'test.css'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Core detection: CSS logical properties -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — CSS logical properties', () => { - test('detects border-inline-start: Npx solid', () => { - const findings = detectAntiPatterns('.card { border-inline-start: 4px solid gold; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects border-inline-end: Npx solid', () => { - const findings = detectAntiPatterns('.card { border-inline-end: 3px solid pink; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects border-inline-start-width: Npx', () => { - const findings = detectAntiPatterns('.card { border-inline-start-width: 5px; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects border-inline-end-width: Npx', () => { - const findings = detectAntiPatterns('.card { border-inline-end-width: 4px; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('ignores border-inline-start: 2px solid', () => { - const findings = detectAntiPatterns('.card { border-inline-start: 2px solid blue; }', 'test.css'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Core detection: JSX inline styles -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — JSX inline styles', () => { - test('detects borderLeft with px value', () => { - const findings = detectAntiPatterns('borderLeft: "4px solid #3b82f6"', 'test.jsx'); - expect(findings).toHaveLength(1); - }); - - test('detects borderRight with px value', () => { - const findings = detectAntiPatterns("borderRight: '5px solid purple'", 'test.tsx'); - expect(findings).toHaveLength(1); - }); - - test('ignores borderLeft: 2px (below threshold)', () => { - const findings = detectAntiPatterns('borderLeft: "2px solid blue"', 'test.jsx'); - expect(findings).toHaveLength(0); - }); - - test('ignores borderTop', () => { - const findings = detectAntiPatterns('borderTop: "4px solid blue"', 'test.jsx'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Top/bottom + rounded detection -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — border accent on rounded', () => { - test('border-t-4 + rounded-lg triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].antipattern).toBe('border-accent-on-rounded'); - }); - - test('border-b-2 + rounded-xl triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - }); - - test('border-t-1 + rounded triggers (even thin)', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - }); - - test('border-t-4 WITHOUT rounded does not trigger', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('border-b-4 WITHOUT rounded does not trigger', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(0); - }); - - test('CSS border-top + border-radius on same line triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].antipattern).toBe('border-accent-on-rounded'); - }); - - test('CSS border-bottom + border-radius on same line triggers', () => { - const findings = detectAntiPatterns('
', 'test.html'); - expect(findings).toHaveLength(1); - }); - - test('CSS border-top WITHOUT border-radius does not trigger', () => { - const findings = detectAntiPatterns('.section { border-top: 4px solid blue; }', 'test.css'); - expect(findings).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Typography: overused fonts -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — overused fonts', () => { - test('detects Inter as primary font', () => { - const findings = detectAntiPatterns("body { font-family: 'Inter', sans-serif; }", 'test.css'); - expect(findings).toHaveLength(1); - expect(findings[0].antipattern).toBe('overused-font'); - }); - - test('detects Roboto as primary font', () => { - const findings = detectAntiPatterns('body { font-family: Roboto, sans-serif; }', 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects Open Sans', () => { - const findings = detectAntiPatterns("body { font-family: 'Open Sans', sans-serif; }", 'test.css'); - expect(findings).toHaveLength(1); - }); - - test('detects Google Fonts import for Inter', () => { - const findings = detectAntiPatterns('', 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toContain('Inter'); +describe('detectText — overused fonts', () => { + test('detects Inter', () => { + const f = detectText("body { font-family: 'Inter', sans-serif; }", 'test.css'); + expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); }); test('does not flag distinctive fonts', () => { - const findings = detectAntiPatterns("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css'); - expect(findings).toHaveLength(0); - }); - - test('does not flag Inter as fallback (not primary)', () => { - const findings = detectAntiPatterns("body { font-family: 'Fraunces', 'Inter', sans-serif; }", 'test.css'); - expect(findings).toHaveLength(0); + const f = detectText("body { font-family: 'Instrument Sans', sans-serif; }", 'test.css'); + expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0); }); }); -// --------------------------------------------------------------------------- -// Typography: single font -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — single font', () => { - test('flags file with only one font', () => { - const content = ``; - const findings = content.split('\n').length >= 20 ? - detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font') : []; - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toContain('poppins'); - }); - - test('does not flag file with two fonts', () => { - const content = ``; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'single-font'); - expect(findings).toHaveLength(0); - }); - - test('does not flag small files', () => { - const findings = detectAntiPatterns("body { font-family: 'Poppins', sans-serif; }", 'test.css'); - const singleFont = findings.filter(f => f.antipattern === 'single-font'); - expect(singleFont).toHaveLength(0); - }); -}); - -// --------------------------------------------------------------------------- -// Typography: flat type hierarchy -// --------------------------------------------------------------------------- - -describe('detectAntiPatterns — flat type hierarchy', () => { - test('flags sizes that are too close together', () => { - const content = ``; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - expect(findings).toHaveLength(1); - expect(findings[0].snippet).toContain('ratio'); +describe('detectText — flat type hierarchy', () => { + test('flags sizes too close together', () => { + const f = detectText('h1{font-size:18px}h2{font-size:16px}h3{font-size:15px}p{font-size:14px}.s{font-size:13px}', 'test.css'); + expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); }); test('passes good hierarchy', () => { - const content = ``; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - expect(findings).toHaveLength(0); - }); - - test('handles rem units', () => { - const content = ``; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - expect(findings).toHaveLength(1); - }); - - test('handles Tailwind text-* classes', () => { - const content = '
small
\n
base
\n
large
'; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - // 14px, 16px, 18px → ratio 1.3:1 → should flag - expect(findings).toHaveLength(1); - }); - - test('passes Tailwind with wide range', () => { - const content = '
small
\n
base
\n
heading
'; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - // 14px, 16px, 36px → ratio 2.6:1 → should pass - expect(findings).toHaveLength(0); - }); - - test('ignores files with fewer than 3 sizes', () => { - const content = ''; - const findings = detectAntiPatterns(content, 'test.html').filter(f => f.antipattern === 'flat-type-hierarchy'); - expect(findings).toHaveLength(0); + const f = detectText('h1{font-size:48px}h2{font-size:32px}p{font-size:16px}.s{font-size:12px}', 'test.css'); + expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0); }); }); // --------------------------------------------------------------------------- -// Fixture files +// jsdom detection (detectHtml) // --------------------------------------------------------------------------- -describe('fixture file scanning', () => { - test('should-flag.html detects side-tabs and accent borders', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'should-flag.html'), 'utf-8'); - const findings = detectAntiPatterns(content, 'should-flag.html'); - // Tailwind (5) + CSS (7) + top/bottom Tailwind (3) + top/bottom CSS (2) - expect(findings.length).toBeGreaterThanOrEqual(13); - expect(findings.some(f => f.antipattern === 'side-tab')).toBe(true); - expect(findings.some(f => f.antipattern === 'border-accent-on-rounded')).toBe(true); +describe('detectHtml — jsdom', () => { + test('catches side-tab from inline style', async () => { + const f = await detectHtml(path.join(FIXTURES, 'should-flag.html')); + expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); }); - test('should-pass.html has zero findings', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'should-pass.html'), 'utf-8'); - const findings = detectAntiPatterns(content, 'should-pass.html'); - expect(findings).toHaveLength(0); + test('catches border-accent-on-rounded', async () => { + const f = await detectHtml(path.join(FIXTURES, 'should-flag.html')); + expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true); }); - test('typography-should-flag.html detects all three font issues', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-flag.html'), 'utf-8'); - const findings = detectAntiPatterns(content, 'typography-should-flag.html'); - expect(findings.some(f => f.antipattern === 'overused-font')).toBe(true); - expect(findings.some(f => f.antipattern === 'single-font')).toBe(true); - expect(findings.some(f => f.antipattern === 'flat-type-hierarchy')).toBe(true); + test('should-pass has zero border findings', async () => { + const f = await detectHtml(path.join(FIXTURES, 'should-pass.html')); + const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded'); + expect(borderFindings).toHaveLength(0); }); - test('typography-should-pass.html has zero findings', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'typography-should-pass.html'), 'utf-8'); - const findings = detectAntiPatterns(content, 'typography-should-pass.html'); - expect(findings).toHaveLength(0); + test('catches side-tab from linked stylesheet', async () => { + const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html')); + expect(f.some(r => r.antipattern === 'side-tab')).toBe(true); }); - test('legitimate-borders.html has minimal false positives', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'legitimate-borders.html'), 'utf-8'); - const findings = detectAntiPatterns(content, 'legitimate-borders.html'); - // Alert banner (colored left border on div) is an acceptable true positive - // Blockquotes, nav, inputs, code spans, timeline (gray) should be skipped - expect(findings.length).toBeLessThanOrEqual(1); - }); -}); - -// --------------------------------------------------------------------------- -// Finding structure -// --------------------------------------------------------------------------- - -describe('finding structure', () => { - test('finding has all required fields', () => { - const findings = detectAntiPatterns('
', 'app.html'); - expect(findings).toHaveLength(1); - const f = findings[0]; - expect(f.antipattern).toBe('side-tab'); - expect(f.name).toBe('Side-tab accent border'); - expect(f.description).toBeTypeOf('string'); - expect(f.file).toBe('app.html'); - expect(f.line).toBe(1); - expect(f.snippet).toBe('border-l-4'); + test('catches border-accent-on-rounded from linked stylesheet', async () => { + const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html')); + expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true); }); - test('reports correct line numbers', () => { - const content = 'line 1\nline 2\n
\nline 4'; - const findings = detectAntiPatterns(content, 'test.html'); - expect(findings).toHaveLength(1); - expect(findings[0].line).toBe(3); + test('does not flag clean card from linked stylesheet', async () => { + const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html')); + const cleanFindings = f.filter(r => r.snippet?.includes('clean')); + expect(cleanFindings).toHaveLength(0); + }); + + test('legitimate-borders has minimal false positives', async () => { + const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html')); + const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded'); + // Alert banner is the only expected detection + expect(borderFindings.length).toBeLessThanOrEqual(1); + }); + + test('typography-should-flag detects all three issues', async () => { + const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html')); + expect(f.some(r => r.antipattern === 'overused-font')).toBe(true); + expect(f.some(r => r.antipattern === 'single-font')).toBe(true); + expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true); + }); + + test('typography-should-pass has zero findings', async () => { + const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html')); + expect(f).toHaveLength(0); }); }); @@ -510,8 +217,8 @@ describe('finding structure', () => { // --------------------------------------------------------------------------- describe('ANTIPATTERNS registry', () => { - test('has at least two entries', () => { - expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(2); + test('has at least 5 entries', () => { + expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(5); }); test('each entry has required fields', () => { @@ -519,56 +226,10 @@ describe('ANTIPATTERNS registry', () => { expect(ap.id).toBeTypeOf('string'); expect(ap.name).toBeTypeOf('string'); expect(ap.description).toBeTypeOf('string'); - const hasMatchers = ap.matchers && ap.matchers.length > 0; - const hasAnalyzers = ap.analyzers && ap.analyzers.length > 0; - expect(hasMatchers || hasAnalyzers).toBe(true); } }); }); -// --------------------------------------------------------------------------- -// Linked stylesheet detection (--deep mode) -// --------------------------------------------------------------------------- - -describe('linked stylesheet detection', () => { - test('regex mode MISSES anti-patterns from linked stylesheets', () => { - const content = fs.readFileSync(path.join(FIXTURES, 'linked-stylesheet.html'), 'utf-8'); - const findings = detectAntiPatterns(content, path.join(FIXTURES, 'linked-stylesheet.html')); - // Regex can't see into external-styles.css — finds nothing border-related - const borderFindings = findings.filter(f => f.antipattern === 'side-tab' || f.antipattern === 'border-accent-on-rounded'); - expect(borderFindings).toHaveLength(0); - }); - - // These tests require jsdom — skip if not available - const hasJsdom = (() => { try { require('jsdom'); return true; } catch { return false; } })(); - const jsdomTest = hasJsdom ? test : test.skip; - - jsdomTest('deep mode CATCHES side-tab from linked stylesheet', async () => { - const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs'); - const filePath = path.join(FIXTURES, 'linked-stylesheet.html'); - const findings = await detectAntiPatternsDeep(filePath); - const sideTabs = findings.filter(f => f.antipattern === 'side-tab'); - expect(sideTabs.length).toBeGreaterThanOrEqual(1); - }); - - jsdomTest('deep mode CATCHES top accent from linked stylesheet', async () => { - const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs'); - const filePath = path.join(FIXTURES, 'linked-stylesheet.html'); - const findings = await detectAntiPatternsDeep(filePath); - const accents = findings.filter(f => f.antipattern === 'border-accent-on-rounded'); - expect(accents.length).toBeGreaterThanOrEqual(1); - }); - - jsdomTest('deep mode does NOT flag clean card from linked stylesheet', async () => { - const { detectAntiPatternsDeep } = await import('../source/skills/critique/scripts/detect-antipatterns.mjs'); - const filePath = path.join(FIXTURES, 'linked-stylesheet.html'); - const findings = await detectAntiPatternsDeep(filePath); - // Should not flag the .external-clean card - const cleanFindings = findings.filter(f => f.snippet && f.snippet.includes('clean')); - expect(cleanFindings).toHaveLength(0); - }); -}); - // --------------------------------------------------------------------------- // walkDir // --------------------------------------------------------------------------- @@ -580,9 +241,8 @@ describe('walkDir', () => { expect(files.every(f => SCANNABLE_EXTENSIONS.has(path.extname(f)))).toBe(true); }); - test('returns empty array for nonexistent dir', () => { - const files = walkDir('/nonexistent/path/12345'); - expect(files).toHaveLength(0); + test('returns empty for nonexistent dir', () => { + expect(walkDir('/nonexistent/path/12345')).toHaveLength(0); }); }); @@ -592,49 +252,50 @@ describe('walkDir', () => { describe('CLI', () => { function run(...args) { - const result = spawnSync('node', [SCRIPT, ...args], { - encoding: 'utf-8', - timeout: 10000, - }); + const result = spawnSync('node', [SCRIPT, ...args], { encoding: 'utf-8', timeout: 15000 }); return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }; } - test('--help exits 0 and shows usage', () => { + test('--help exits 0', () => { const { stdout, code } = run('--help'); expect(code).toBe(0); expect(stdout).toContain('Usage:'); }); - test('clean file exits 0', () => { + test('should-pass exits 0', () => { const { code } = run(path.join(FIXTURES, 'should-pass.html')); expect(code).toBe(0); }); - test('file with anti-patterns exits 2', () => { + test('should-flag exits 2 with findings', () => { const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html')); expect(code).toBe(2); expect(stderr).toContain('side-tab'); }); - test('--json outputs valid JSON array', () => { + test('--json outputs valid JSON', () => { const { stderr, code } = run('--json', path.join(FIXTURES, 'should-flag.html')); expect(code).toBe(2); const parsed = JSON.parse(stderr.trim()); expect(parsed).toBeArray(); expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0].antipattern).toBe('side-tab'); }); - test('--json on clean file outputs empty array on stdout', () => { + test('--json on clean file outputs empty array', () => { const { stdout, code } = run('--json', path.join(FIXTURES, 'should-pass.html')); expect(code).toBe(0); expect(JSON.parse(stdout.trim())).toEqual([]); }); - test('scans directory recursively', () => { - const { code, stderr } = run(FIXTURES); + test('--fast mode works', () => { + const { code } = run('--fast', path.join(FIXTURES, 'should-flag.html')); expect(code).toBe(2); - expect(stderr).toContain('anti-pattern'); + }); + + test('linked stylesheet detected (jsdom default)', () => { + const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html')); + expect(code).toBe(2); + expect(stderr).toContain('side-tab'); }); test('warns on nonexistent path', () => { diff --git a/tests/fixtures/antipatterns/legitimate-borders.html b/tests/fixtures/antipatterns/legitimate-borders.html index 115b8ede0..4b89da377 100644 --- a/tests/fixtures/antipatterns/legitimate-borders.html +++ b/tests/fixtures/antipatterns/legitimate-borders.html @@ -108,6 +108,6 @@ Warning: Your trial expires in 3 days. Upgrade now
- + diff --git a/tests/fixtures/antipatterns/linked-stylesheet.html b/tests/fixtures/antipatterns/linked-stylesheet.html index 46d4f47a3..a679dd432 100644 --- a/tests/fixtures/antipatterns/linked-stylesheet.html +++ b/tests/fixtures/antipatterns/linked-stylesheet.html @@ -43,6 +43,6 @@

Uniform 1px border — should NOT flag.

- + diff --git a/tests/fixtures/antipatterns/should-flag.html b/tests/fixtures/antipatterns/should-flag.html index 2d5306f71..714bff2ed 100644 --- a/tests/fixtures/antipatterns/should-flag.html +++ b/tests/fixtures/antipatterns/should-flag.html @@ -131,6 +131,6 @@

Inline dark card with side-tab.

- + diff --git a/tests/fixtures/antipatterns/should-pass.html b/tests/fixtures/antipatterns/should-pass.html index cc1310281..5ea9f3baf 100644 --- a/tests/fixtures/antipatterns/should-pass.html +++ b/tests/fixtures/antipatterns/should-pass.html @@ -79,6 +79,6 @@

Shadow only. Clean.

- + diff --git a/tests/fixtures/antipatterns/typography-should-flag.html b/tests/fixtures/antipatterns/typography-should-flag.html index 7c4caacc9..e51ece009 100644 --- a/tests/fixtures/antipatterns/typography-should-flag.html +++ b/tests/fixtures/antipatterns/typography-should-flag.html @@ -34,6 +34,6 @@

A Subheading

Can you tell this is a subheading? Exactly.

- + diff --git a/tests/fixtures/antipatterns/typography-should-pass.html b/tests/fixtures/antipatterns/typography-should-pass.html index 82d29a1f1..67864823b 100644 --- a/tests/fixtures/antipatterns/typography-should-pass.html +++ b/tests/fixtures/antipatterns/typography-should-pass.html @@ -40,6 +40,6 @@

Strong Size Hierarchy

Sizes range from 12px to 48px — a 4:1 ratio with clear visual steps.

Caption text is clearly distinct from body.

- +