diff --git a/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js b/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
deleted file mode 100644
index c0dd172f9..000000000
--- a/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
+++ /dev/null
@@ -1,335 +0,0 @@
-/**
- * Anti-Pattern Browser Detector for Impeccable
- * GENERATED — do not edit. Source: detect-antipatterns-core.mjs + this wrapper.
- * Rebuild: node scripts/build-browser-detector.js
- *
- * Usage:
- * Re-scan: window.impeccableScan()
- */
-(function () {
- if (typeof window === 'undefined') return;
-
- const LABEL_BG = 'oklch(55% 0.25 350)';
- const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
-
- // ===========================================================================
- // Core detection logic (injected from detect-antipatterns-core.mjs at build)
- // ===========================================================================
-
- // {{CORE_INJECTION_POINT}}
-
- // ===========================================================================
- // Browser-specific: DOM element adapters
- // ===========================================================================
-
- function resolveBackground(el) {
- let current = el;
- while (current && current.nodeType === 1) {
- const bg = parseRgb(getComputedStyle(current).backgroundColor);
- if (bg && bg.a > 0.1) return bg;
- current = current.parentElement;
- }
- return { r: 255, g: 255, b: 255 };
- }
-
- function checkElementBordersDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag)) return [];
- const rect = el.getBoundingClientRect();
- if (rect.width < 20 || rect.height < 20) return [];
- const style = getComputedStyle(el);
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {}, colors = {};
- for (const s of sides) {
- widths[s] = parseFloat(style[`border${s}Width`]) || 0;
- colors[s] = style[`border${s}Color`] || '';
- }
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
- }
-
- function checkElementColorsDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag)) return [];
- const rect = el.getBoundingClientRect();
- if (rect.width < 10 || rect.height < 10) return [];
- const style = getComputedStyle(el);
- const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
- return checkColors({
- tag,
- textColor: parseRgb(style.color),
- bgColor: parseRgb(style.backgroundColor),
- effectiveBg: resolveBackground(el),
- fontSize: parseFloat(style.fontSize) || 16,
- fontWeight: parseInt(style.fontWeight) || 400,
- hasDirectText,
- bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
- bgImage: style.backgroundImage || '',
- classList: el.getAttribute('class') || '',
- });
- }
-
- // ===========================================================================
- // Browser-specific: Page-level checks
- // ===========================================================================
-
- function checkTypography() {
- const findings = [];
- 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);
- }
- }
- }
-
- const html = document.documentElement.outerHTML;
- const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
- let m;
- while ((m = gfRe.exec(html)) !== null) {
- for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
- fonts.add(f);
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
- }
- }
-
- for (const font of overusedFound) {
- findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
- }
- if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
- findings.push({ type: 'single-font', detail: `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({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
- }
- }
-
- return findings;
- }
-
- function isCardLikeDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
- const style = getComputedStyle(el);
- const cls = el.getAttribute('class') || '';
- const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
- const hasBorder = /\bborder\b/.test(cls);
- const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
- const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
- return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
- }
-
- function checkLayout() {
- const findings = [];
- const flaggedEls = new Set();
-
- for (const el of document.querySelectorAll('*')) {
- if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
- const cls = el.getAttribute('class') || '';
- const style = getComputedStyle(el);
- if (style.position === 'absolute' || style.position === 'fixed') continue;
- if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
- if ((el.textContent?.trim().length || 0) < 10) continue;
- const rect = el.getBoundingClientRect();
- if (rect.width < 50 || rect.height < 30) continue;
-
- let parent = el.parentElement;
- while (parent) {
- if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
- parent = parent.parentElement;
- }
- }
-
- for (const el of flaggedEls) {
- let isAncestor = false;
- for (const other of flaggedEls) {
- if (other !== el && el.contains(other)) { isAncestor = true; break; }
- }
- if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
- }
-
- return findings;
- }
-
- // ===========================================================================
- // Highlighting & UI
- // ===========================================================================
-
- const overlays = [];
- const TYPE_LABELS = {};
- for (const ap of ANTIPATTERNS) {
- TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
- }
-
- function highlight(el, findings) {
- const rect = el.getBoundingClientRect();
- const outline = document.createElement('div');
- outline.className = 'impeccable-overlay';
- Object.assign(outline.style, {
- position: 'absolute',
- top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
- width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
- border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
- pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
- });
-
- const label = document.createElement('div');
- label.className = 'impeccable-label';
- label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
- Object.assign(label.style, {
- position: 'absolute', top: '-20px', left: '0',
- background: LABEL_BG, color: 'white',
- fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
- padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
- lineHeight: '16px', letterSpacing: '0.02em',
- });
- outline.appendChild(label);
-
- const tooltip = document.createElement('div');
- tooltip.className = 'impeccable-tooltip';
- tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('
');
- Object.assign(tooltip.style, {
- position: 'absolute', bottom: '-28px', left: '0',
- background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
- fontSize: '11px', fontFamily: 'ui-monospace, monospace',
- padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
- lineHeight: '16px', display: 'none', zIndex: '100000',
- });
- outline.appendChild(tooltip);
-
- outline.addEventListener('mouseenter', () => {
- outline.style.pointerEvents = 'auto';
- tooltip.style.display = 'block';
- outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
- });
- outline.addEventListener('mouseleave', () => {
- outline.style.pointerEvents = 'none';
- tooltip.style.display = 'none';
- outline.style.background = 'none';
- });
-
- document.body.appendChild(outline);
- overlays.push(outline);
- }
-
- function showPageBanner(findings) {
- if (!findings.length) return;
- const banner = document.createElement('div');
- banner.className = 'impeccable-overlay';
- Object.assign(banner.style, {
- position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
- background: LABEL_BG, color: 'white',
- fontFamily: 'system-ui, sans-serif', fontSize: '13px',
- padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
- gap: '12px', alignItems: 'center', pointerEvents: 'auto',
- });
- for (const f of findings) {
- const tag = document.createElement('span');
- tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
- Object.assign(tag.style, {
- background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
- borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
- });
- banner.appendChild(tag);
- }
- const close = document.createElement('button');
- close.textContent = '\u00d7';
- Object.assign(close.style, {
- marginLeft: 'auto', background: 'none', border: 'none',
- color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
- });
- close.addEventListener('click', () => banner.remove());
- banner.appendChild(close);
- document.body.appendChild(banner);
- overlays.push(banner);
- }
-
- function printSummary(allFindings) {
- if (allFindings.length === 0) {
- console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
- return;
- }
- console.group(
- `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
- 'color: oklch(60% 0.25 350); font-weight: bold'
- );
- for (const { el, findings } of allFindings) {
- for (const f of findings) {
- console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
- 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
- }
- }
- console.groupEnd();
- }
-
- // ===========================================================================
- // Main scan
- // ===========================================================================
-
- function scan() {
- for (const o of overlays) o.remove();
- overlays.length = 0;
- const allFindings = [];
-
- for (const el of document.querySelectorAll('*')) {
- if (el.classList.contains('impeccable-overlay') ||
- el.classList.contains('impeccable-label') ||
- el.classList.contains('impeccable-tooltip')) continue;
-
- const findings = [
- ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
- ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
- ];
-
- if (findings.length > 0) {
- highlight(el, findings);
- allFindings.push({ el, findings });
- }
- }
-
- const typoFindings = checkTypography();
- if (typoFindings.length > 0) {
- showPageBanner(typoFindings);
- allFindings.push({ el: document.body, findings: typoFindings });
- }
-
- const layoutFindings = checkLayout();
- for (const f of layoutFindings) {
- const el = f.el || document.body;
- delete f.el;
- highlight(el, [f]);
- allFindings.push({ el, findings: [f] });
- }
-
- printSummary(allFindings);
- return allFindings;
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
- } else {
- setTimeout(scan, 100);
- }
-
- window.impeccableScan = scan;
-})();
diff --git a/.claude/skills/critique/scripts/detect-antipatterns-browser.js b/.claude/skills/critique/scripts/detect-antipatterns-browser.js
new file mode 100644
index 000000000..d5ec71eee
--- /dev/null
+++ b/.claude/skills/critique/scripts/detect-antipatterns-browser.js
@@ -0,0 +1,914 @@
+/**
+ * Anti-Pattern Browser Detector for Impeccable
+ * GENERATED — do not edit. Source: detect-antipatterns.mjs
+ * Rebuild: node scripts/build-browser-detector.js
+ *
+ * Usage:
+ * Re-scan: window.impeccableScan()
+ */
+(function () {
+if (typeof window === 'undefined') return;
+
+/**
+ * Anti-Pattern Detector for Impeccable
+ *
+ * Universal file — auto-detects environment (browser vs Node) and adapts.
+ *
+ * Node usage:
+ * 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
+ *
+ * Browser usage:
+ *
+ * Re-scan: window.impeccableScan()
+ *
+ * Exit codes: 0 = clean, 2 = findings
+ */
+
+// ─── Environment ────────────────────────────────────────────────────────────
+
+const IS_BROWSER = true;
+const IS_NODE = !IS_BROWSER;
+
+
+// ─── Section 1: 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',
+]);
+
+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).',
+ },
+ {
+ id: 'pure-black-white',
+ name: 'Pure black background',
+ description:
+ 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
+ },
+ {
+ id: 'gray-on-color',
+ name: 'Gray text on colored background',
+ description:
+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
+ },
+ {
+ id: 'low-contrast',
+ name: 'Low contrast text',
+ description:
+ 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
+ },
+ {
+ id: 'gradient-text',
+ name: 'Gradient text',
+ description:
+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
+ },
+ {
+ id: 'ai-color-palette',
+ name: 'AI color palette',
+ description:
+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
+ },
+ {
+ id: 'nested-cards',
+ name: 'Nested cards',
+ description:
+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
+ },
+ {
+ id: 'monotonous-spacing',
+ name: 'Monotonous spacing',
+ description:
+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
+ },
+ {
+ id: 'everything-centered',
+ name: 'Everything centered',
+ description:
+ 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
+ },
+];
+
+// ─── Section 2: Color Utilities ─────────────────────────────────────────────
+
+function isNeutralColor(color) {
+ if (!color || color === 'transparent') return true;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!m) return true;
+ return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
+}
+
+function parseRgb(color) {
+ if (!color || color === 'transparent') return null;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!m) return null;
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
+}
+
+function relativeLuminance({ r, g, b }) {
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
+ );
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
+}
+
+function contrastRatio(c1, c2) {
+ const l1 = relativeLuminance(c1);
+ const l2 = relativeLuminance(c2);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+}
+
+function hasChroma(c, threshold = 30) {
+ if (!c) return false;
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
+}
+
+function getHue(c) {
+ if (!c) return 0;
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ if (max === min) return 0;
+ const d = max - min;
+ let h;
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+ else if (max === g) h = ((b - r) / d + 2) / 6;
+ else h = ((r - g) / d + 4) / 6;
+ return Math.round(h * 360);
+}
+
+function colorToHex(c) {
+ if (!c) return '?';
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+// ─── Section 3: Pure Detection ──────────────────────────────────────────────
+
+function checkBorders(tag, widths, colors, radius) {
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+
+ for (const side of sides) {
+ const w = widths[side];
+ if (w < 1 || isNeutralColor(colors[side])) continue;
+
+ const otherSides = sides.filter(s => s !== side);
+ const maxOther = Math.max(...otherSides.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` });
+ }
+ }
+
+ return findings;
+}
+
+function checkColors(opts) {
+ const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+
+ // Pure black background
+ if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
+ findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
+ }
+
+ if (hasDirectText && textColor) {
+ // Gray on colored background
+ const textLum = relativeLuminance(textColor);
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
+ if (isGray && hasChroma(effectiveBg, 40)) {
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
+ }
+
+ // Low contrast (WCAG AA)
+ const ratio = contrastRatio(textColor, effectiveBg);
+ const isHeading = ['h1', 'h2', 'h3'].includes(tag);
+ const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
+ const threshold = isLargeText ? 3.0 : 4.5;
+ if (ratio < threshold) {
+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
+ }
+
+ // AI palette: purple/violet on headings
+ if (hasChroma(textColor, 50)) {
+ const hue = getHue(textColor);
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
+ }
+ }
+ }
+
+ // Gradient text
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
+ }
+
+ // Tailwind class checks
+ if (classList) {
+ if (/\bbg-black\b/.test(classList)) {
+ findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
+ }
+
+ const grayMatch = classList.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
+ const colorBgMatch = classList.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
+ if (grayMatch && colorBgMatch) {
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
+ }
+
+ if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
+ }
+
+ const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
+ }
+
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
+ if (!hasShadow && !hasBorder) return false;
+ return hasRadius || hasBg;
+}
+
+// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
+
+function resolveBackground(el, win) {
+ let current = el;
+ while (current && current.nodeType === 1) {
+ const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
+ let bg = parseRgb(style.backgroundColor);
+ if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
+ // jsdom doesn't decompose background shorthand — parse raw style attr
+ const rawStyle = current.getAttribute?.('style') || '';
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
+ bg = parseRgb(inlineBg);
+ if (!bg && inlineBg) {
+ const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
+ if (hexMatch) {
+ const h = hexMatch[1];
+ if (h.length === 6) {
+ bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
+ } else {
+ bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
+ }
+ }
+ }
+ }
+ if (bg && bg.a > 0.1) {
+ if (IS_BROWSER || bg.a >= 0.5) return bg;
+ }
+ current = current.parentElement;
+ }
+ return { r: 255, g: 255, b: 255 };
+}
+
+// ─── Section 5: Element Adapters ────────────────────────────────────────────
+
+// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
+
+function checkElementBordersDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 20 || rect.height < 20) return [];
+ const style = getComputedStyle(el);
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
+}
+
+function checkElementColorsDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 10 || rect.height < 10) return [];
+ const style = getComputedStyle(el);
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
+ return checkColors({
+ tag,
+ textColor: parseRgb(style.color),
+ bgColor: parseRgb(style.backgroundColor),
+ effectiveBg: resolveBackground(el),
+ fontSize: parseFloat(style.fontSize) || 16,
+ fontWeight: parseInt(style.fontWeight) || 400,
+ hasDirectText,
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
+ bgImage: style.backgroundImage || '',
+ classList: el.getAttribute('class') || '',
+ });
+}
+
+// Node adapters — take pre-extracted jsdom computed style
+
+function checkElementBorders(tag, style) {
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
+}
+
+function checkElementColors(el, style, tag, window) {
+ const hasText = el.textContent?.trim().length > 0;
+ const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
+
+ return checkColors({
+ tag,
+ textColor: parseRgb(style.color),
+ bgColor: parseRgb(style.backgroundColor),
+ effectiveBg: resolveBackground(el, window),
+ fontSize: parseFloat(style.fontSize) || 16,
+ fontWeight: parseInt(style.fontWeight) || 400,
+ hasDirectText,
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
+ bgImage: style.backgroundImage || '',
+ classList: el.getAttribute?.('class') || el.className || '',
+ });
+}
+
+// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
+
+// Browser page-level checks — use document/getComputedStyle globals
+
+function checkTypography() {
+ const findings = [];
+ 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);
+ }
+ }
+ }
+
+ const html = document.documentElement.outerHTML;
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
+ let m;
+ while ((m = gfRe.exec(html)) !== null) {
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
+ fonts.add(f);
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
+ }
+ }
+
+ for (const font of overusedFound) {
+ findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
+ }
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
+ findings.push({ type: 'single-font', detail: `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({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
+ const style = getComputedStyle(el);
+ const cls = el.getAttribute('class') || '';
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
+ const hasBorder = /\bborder\b/.test(cls);
+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
+}
+
+function checkLayout() {
+ const findings = [];
+ const flaggedEls = new Set();
+
+ for (const el of document.querySelectorAll('*')) {
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
+ const cls = el.getAttribute('class') || '';
+ const style = getComputedStyle(el);
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
+ if ((el.textContent?.trim().length || 0) < 10) continue;
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 50 || rect.height < 30) continue;
+
+ let parent = el.parentElement;
+ while (parent) {
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
+ parent = parent.parentElement;
+ }
+ }
+
+ for (const el of flaggedEls) {
+ let isAncestor = false;
+ for (const other of flaggedEls) {
+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
+ }
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
+ }
+
+ return findings;
+}
+
+// Node page-level checks — take document/window as parameters
+
+function checkPageTypography(doc, win) {
+ const findings = [];
+
+ const fonts = new Set();
+ const overusedFound = new Set();
+
+ for (const sheet of doc.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 = doc.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 = doc.querySelectorAll('*');
+ if (els.length >= 20) {
+ findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` });
+ }
+ }
+
+ // Flat type hierarchy
+ const sizes = new Set();
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
+ for (const el of textEls) {
+ const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
+ // Filter out sub-8px values (jsdom doesn't resolve relative units properly)
+ if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 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)` });
+ }
+ }
+
+ // Pure black background (regex on raw HTML)
+ const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
+ if (pureBlackBgRe.test(html)) {
+ findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
+ }
+
+ // AI color palette: purple/violet in raw CSS
+ const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
+ if (purpleHexRe.test(html)) {
+ const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
+ if (purpleTextRe.test(html)) {
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
+ }
+ }
+
+ // Gradient text (regex on raw HTML — jsdom doesn't compute background-clip)
+ const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
+ let gm;
+ while ((gm = gradientRe.exec(html)) !== null) {
+ const start = Math.max(0, gm.index - 200);
+ const context = html.substring(start, gm.index + gm[0].length + 200);
+ if (/gradient/i.test(context)) {
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
+ break;
+ }
+ }
+
+ // Tailwind gradient text
+ if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
+ }
+
+ return findings;
+}
+
+function isCardLike(el, win) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
+
+ const style = win.getComputedStyle(el);
+ const rawStyle = el.getAttribute?.('style') || '';
+ const cls = el.getAttribute?.('class') || '';
+
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
+ /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
+ const hasBorder = /\bborder\b/.test(cls);
+ const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
+ /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
+ const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
+ /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
+
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
+}
+
+function checkPageLayout(doc, win) {
+ const findings = [];
+
+ // Nested cards
+ const allEls = doc.querySelectorAll('*');
+ const flaggedEls = new Set();
+ for (const el of allEls) {
+ if (!isCardLike(el, win)) continue;
+ if (flaggedEls.has(el)) continue;
+
+ const tag = el.tagName.toLowerCase();
+ const cls = el.getAttribute?.('class') || '';
+ const rawStyle = el.getAttribute?.('style') || '';
+
+ if (['pre', 'code'].includes(tag)) continue;
+ if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
+ if ((el.textContent?.trim().length || 0) < 10) continue;
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
+
+ // Walk up to find card-like ancestor
+ let parent = el.parentElement;
+ while (parent) {
+ if (isCardLike(parent, win)) {
+ flaggedEls.add(el);
+ break;
+ }
+ parent = parent.parentElement;
+ }
+ }
+
+ // Only report innermost nested cards
+ for (const el of flaggedEls) {
+ let isAncestorOfFlagged = false;
+ for (const other of flaggedEls) {
+ if (other !== el && el.contains(other)) {
+ isAncestorOfFlagged = true;
+ break;
+ }
+ }
+ if (!isAncestorOfFlagged) {
+ findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
+ }
+ }
+
+ // Monotonous spacing (regex on raw HTML)
+ const spacingValues = [];
+ const html = doc.documentElement?.outerHTML || '';
+
+ const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
+ let sm;
+ while ((sm = spacingRe.exec(html)) !== null) {
+ const v = parseInt(sm[1], 10);
+ if (v > 0 && v < 200) spacingValues.push(v);
+ }
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
+ while ((sm = gapRe.exec(html)) !== null) {
+ spacingValues.push(parseInt(sm[1], 10));
+ }
+ const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
+ while ((sm = twSpaceRe.exec(html)) !== null) {
+ spacingValues.push(parseInt(sm[1], 10) * 4);
+ }
+ const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
+ while ((sm = remSpacingRe.exec(html)) !== null) {
+ const v = Math.round(parseFloat(sm[1]) * 16);
+ if (v > 0 && v < 200) spacingValues.push(v);
+ }
+
+ const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
+ if (roundedSpacing.length >= 10) {
+ const counts = {};
+ for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
+ const maxCount = Math.max(...Object.values(counts));
+ const dominantPct = maxCount / roundedSpacing.length;
+ const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
+ if (dominantPct > 0.6 && unique.length <= 3) {
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
+ findings.push({
+ id: 'monotonous-spacing',
+ snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
+ });
+ }
+ }
+
+ // Everything centered
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
+ let centeredCount = 0;
+ let totalText = 0;
+ for (const el of textEls) {
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
+ if (!hasDirectText) continue;
+ totalText++;
+
+ let cur = el;
+ let isCentered = false;
+ while (cur && cur.nodeType === 1) {
+ const rawStyle = cur.getAttribute?.('style') || '';
+ const cls = cur.getAttribute?.('class') || '';
+ if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
+ isCentered = true;
+ break;
+ }
+ if (cur.tagName === 'BODY') break;
+ cur = cur.parentElement;
+ }
+ if (isCentered) centeredCount++;
+ }
+
+ if (totalText >= 5 && centeredCount / totalText > 0.7) {
+ findings.push({
+ id: 'everything-centered',
+ snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
+ });
+ }
+
+ return findings;
+}
+
+// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
+
+if (IS_BROWSER) {
+ const LABEL_BG = 'oklch(55% 0.25 350)';
+ const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
+
+ const overlays = [];
+ const TYPE_LABELS = {};
+ for (const ap of ANTIPATTERNS) {
+ TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
+ }
+
+ const highlight = function(el, findings) {
+ const rect = el.getBoundingClientRect();
+ const outline = document.createElement('div');
+ outline.className = 'impeccable-overlay';
+ Object.assign(outline.style, {
+ position: 'absolute',
+ top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
+ border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
+ pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
+ });
+
+ const label = document.createElement('div');
+ label.className = 'impeccable-label';
+ label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
+ Object.assign(label.style, {
+ position: 'absolute', top: '-20px', left: '0',
+ background: LABEL_BG, color: 'white',
+ fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
+ padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', letterSpacing: '0.02em',
+ });
+ outline.appendChild(label);
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'impeccable-tooltip';
+ tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('
');
+ Object.assign(tooltip.style, {
+ position: 'absolute', bottom: '-28px', left: '0',
+ background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
+ fontSize: '11px', fontFamily: 'ui-monospace, monospace',
+ padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', display: 'none', zIndex: '100000',
+ });
+ outline.appendChild(tooltip);
+
+ outline.addEventListener('mouseenter', () => {
+ outline.style.pointerEvents = 'auto';
+ tooltip.style.display = 'block';
+ outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
+ });
+ outline.addEventListener('mouseleave', () => {
+ outline.style.pointerEvents = 'none';
+ tooltip.style.display = 'none';
+ outline.style.background = 'none';
+ });
+
+ document.body.appendChild(outline);
+ overlays.push(outline);
+ };
+
+ const showPageBanner = function(findings) {
+ if (!findings.length) return;
+ const banner = document.createElement('div');
+ banner.className = 'impeccable-overlay';
+ Object.assign(banner.style, {
+ position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
+ background: LABEL_BG, color: 'white',
+ fontFamily: 'system-ui, sans-serif', fontSize: '13px',
+ padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
+ gap: '12px', alignItems: 'center', pointerEvents: 'auto',
+ });
+ for (const f of findings) {
+ const tag = document.createElement('span');
+ tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
+ Object.assign(tag.style, {
+ background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
+ borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
+ });
+ banner.appendChild(tag);
+ }
+ const close = document.createElement('button');
+ close.textContent = '\u00d7';
+ Object.assign(close.style, {
+ marginLeft: 'auto', background: 'none', border: 'none',
+ color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
+ });
+ close.addEventListener('click', () => banner.remove());
+ banner.appendChild(close);
+ document.body.appendChild(banner);
+ overlays.push(banner);
+ };
+
+ const printSummary = function(allFindings) {
+ if (allFindings.length === 0) {
+ console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
+ return;
+ }
+ console.group(
+ `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
+ 'color: oklch(60% 0.25 350); font-weight: bold'
+ );
+ for (const { el, findings } of allFindings) {
+ for (const f of findings) {
+ console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
+ 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
+ }
+ }
+ console.groupEnd();
+ };
+
+ const scan = function() {
+ for (const o of overlays) o.remove();
+ overlays.length = 0;
+ const allFindings = [];
+
+ for (const el of document.querySelectorAll('*')) {
+ if (el.classList.contains('impeccable-overlay') ||
+ el.classList.contains('impeccable-label') ||
+ el.classList.contains('impeccable-tooltip')) continue;
+
+ const findings = [
+ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ];
+
+ if (findings.length > 0) {
+ highlight(el, findings);
+ allFindings.push({ el, findings });
+ }
+ }
+
+ const typoFindings = checkTypography();
+ if (typoFindings.length > 0) {
+ showPageBanner(typoFindings);
+ allFindings.push({ el: document.body, findings: typoFindings });
+ }
+
+ const layoutFindings = checkLayout();
+ for (const f of layoutFindings) {
+ const el = f.el || document.body;
+ delete f.el;
+ highlight(el, [f]);
+ allFindings.push({ el, findings: [f] });
+ }
+
+ printSummary(allFindings);
+ return allFindings;
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
+ } else {
+ setTimeout(scan, 100);
+ }
+
+ window.impeccableScan = scan;
+}
+
+// ─── Section 8: Node Engine ─────────────────────────────────────────────────
+
+// ─── Section 9: Exports ─────────────────────────────────────────────────────
+
+})();
diff --git a/.claude/skills/critique/scripts/detect-antipatterns-core.mjs b/.claude/skills/critique/scripts/detect-antipatterns-core.mjs
deleted file mode 100644
index ef1982182..000000000
--- a/.claude/skills/critique/scripts/detect-antipatterns-core.mjs
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * Anti-Pattern Detection Core — shared between CLI and browser.
- *
- * All functions here are pure (no DOM/Node dependencies) and work in both
- * jsdom and real browser environments. They take primitive/data arguments,
- * not raw DOM elements.
- */
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-export 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',
-]);
-
-export const OVERUSED_FONTS = new Set([
- 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
-]);
-
-export 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',
-]);
-
-export 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).',
- },
- {
- id: 'pure-black-white',
- name: 'Pure black background',
- description:
- 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
- },
- {
- id: 'gray-on-color',
- name: 'Gray text on colored background',
- description:
- 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
- },
- {
- id: 'low-contrast',
- name: 'Low contrast text',
- description:
- 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
- },
- {
- id: 'gradient-text',
- name: 'Gradient text',
- description:
- 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
- },
- {
- id: 'ai-color-palette',
- name: 'AI color palette',
- description:
- 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
- },
- {
- id: 'nested-cards',
- name: 'Nested cards',
- description:
- 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
- },
- {
- id: 'monotonous-spacing',
- name: 'Monotonous spacing',
- description:
- 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
- },
- {
- id: 'everything-centered',
- name: 'Everything centered',
- description:
- 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
- },
-];
-
-// ---------------------------------------------------------------------------
-// Color utilities
-// ---------------------------------------------------------------------------
-
-export function isNeutralColor(color) {
- if (!color || color === 'transparent') return true;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (!m) return true;
- return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
-}
-
-export function parseRgb(color) {
- if (!color || color === 'transparent') return null;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
- if (!m) return null;
- return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
-}
-
-export function relativeLuminance({ r, g, b }) {
- const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
- );
- return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
-}
-
-export function contrastRatio(c1, c2) {
- const l1 = relativeLuminance(c1);
- const l2 = relativeLuminance(c2);
- return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
-}
-
-export function hasChroma(c, threshold = 30) {
- if (!c) return false;
- return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
-}
-
-export function getHue(c) {
- if (!c) return 0;
- const r = c.r / 255, g = c.g / 255, b = c.b / 255;
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
- if (max === min) return 0;
- const d = max - min;
- let h;
- if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
- else if (max === g) h = ((b - r) / d + 2) / 6;
- else h = ((r - g) / d + 4) / 6;
- return Math.round(h * 360);
-}
-
-export function colorToHex(c) {
- if (!c) return '?';
- return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
-}
-
-// ---------------------------------------------------------------------------
-// Element-level detection (pure: takes data, not DOM elements)
-// ---------------------------------------------------------------------------
-
-/**
- * Check border widths/colors/radius for side-tab and accent-on-rounded patterns.
- * @param {string} tag Element tag name (lowercase)
- * @param {{ Top: number, Right: number, Bottom: number, Left: number }} widths Border widths in px
- * @param {{ Top: string, Right: string, Bottom: string, Left: string }} colors Border colors as rgb() strings
- * @param {number} radius Border radius in px
- * @returns {Array<{ id: string, snippet: string }>}
- */
-export function checkBorders(tag, widths, colors, radius) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
-
- for (const side of sides) {
- const w = widths[side];
- if (w < 1 || isNeutralColor(colors[side])) continue;
-
- const otherSides = sides.filter(s => s !== side);
- const maxOther = Math.max(...otherSides.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` });
- }
- }
-
- return findings;
-}
-
-/**
- * Check colors for anti-patterns given pre-extracted data.
- * @param {object} opts
- * @param {string} opts.tag
- * @param {object|null} opts.textColor Parsed RGB
- * @param {object|null} opts.bgColor Parsed RGB (direct background)
- * @param {object} opts.effectiveBg Resolved background (walked ancestors)
- * @param {number} opts.fontSize In px
- * @param {number} opts.fontWeight
- * @param {boolean} opts.hasDirectText
- * @param {string} opts.bgClip Computed background-clip value
- * @param {string} opts.bgImage Computed background-image value
- * @param {string} opts.classList Raw class attribute string
- * @returns {Array<{ id: string, snippet: string }>}
- */
-export function checkColors(opts) {
- const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
- // Pure black background
- if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
- findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
- }
-
- if (hasDirectText && textColor) {
- // Gray on colored background
- const textLum = relativeLuminance(textColor);
- const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
- if (isGray && hasChroma(effectiveBg, 40)) {
- findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
- }
-
- // Low contrast (WCAG AA)
- const ratio = contrastRatio(textColor, effectiveBg);
- const isHeading = ['h1', 'h2', 'h3'].includes(tag);
- const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
- const threshold = isLargeText ? 3.0 : 4.5;
- if (ratio < threshold) {
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
- }
-
- // AI palette: purple/violet on headings
- if (hasChroma(textColor, 50)) {
- const hue = getHue(textColor);
- if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
- findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
- }
- }
- }
-
- // Gradient text
- if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- }
-
- // Tailwind class checks
- if (classList) {
- if (/\bbg-black\b/.test(classList)) {
- findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
- }
-
- const grayMatch = classList.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
- const colorBgMatch = classList.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
- if (grayMatch && colorBgMatch) {
- findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
- }
-
- if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
- findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
- }
-
- const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
- if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
- findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
- }
-
- if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
- }
- }
-
- return findings;
-}
-
-/**
- * Check if an element's properties make it "card-like".
- */
-export function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
- if (!hasShadow && !hasBorder) return false;
- return hasRadius || hasBg;
-}
diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs
index 5e7d91c65..4a45d8477 100644
--- a/.claude/skills/critique/scripts/detect-antipatterns.mjs
+++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs
@@ -3,61 +3,301 @@
/**
* Anti-Pattern Detector for Impeccable
*
- * 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.
+ * Universal file — auto-detects environment (browser vs Node) and adapts.
*
- * Usage:
+ * Node usage:
* 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
*
+ * Browser usage:
+ *
+ * Re-scan: window.impeccableScan()
+ *
* Exit codes: 0 = clean, 2 = findings
*/
-import fs from 'fs';
-import path from 'path';
-import {
- SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, ANTIPATTERNS,
- isNeutralColor, parseRgb, relativeLuminance, contrastRatio,
- hasChroma, getHue, colorToHex,
- checkBorders, checkColors, isCardLikeFromProps,
-} from './detect-antipatterns-core.mjs';
+// ─── Environment ────────────────────────────────────────────────────────────
-// ANTIPATTERNS, constants, and color utilities imported from core
+const IS_BROWSER = typeof window !== 'undefined';
+const IS_NODE = !IS_BROWSER;
-/** Check if content looks like a full page (not a component/partial) */
-function isFullPage(content) {
- // Strip HTML comments before checking — they might mention /
in prose
- const stripped = content.replace(//g, '');
- return /]|]/i.test(stripped);
+// @browser-strip-start
+let fs, path;
+if (!IS_BROWSER) {
+ fs = (await import('node:fs')).default;
+ path = (await import('node:path')).default;
+}
+// @browser-strip-end
+
+// ─── Section 1: 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',
+]);
+
+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).',
+ },
+ {
+ id: 'pure-black-white',
+ name: 'Pure black background',
+ description:
+ 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
+ },
+ {
+ id: 'gray-on-color',
+ name: 'Gray text on colored background',
+ description:
+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
+ },
+ {
+ id: 'low-contrast',
+ name: 'Low contrast text',
+ description:
+ 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
+ },
+ {
+ id: 'gradient-text',
+ name: 'Gradient text',
+ description:
+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
+ },
+ {
+ id: 'ai-color-palette',
+ name: 'AI color palette',
+ description:
+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
+ },
+ {
+ id: 'nested-cards',
+ name: 'Nested cards',
+ description:
+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
+ },
+ {
+ id: 'monotonous-spacing',
+ name: 'Monotonous spacing',
+ description:
+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
+ },
+ {
+ id: 'everything-centered',
+ name: 'Everything centered',
+ description:
+ 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
+ },
+];
+
+// ─── Section 2: Color Utilities ─────────────────────────────────────────────
+
+function isNeutralColor(color) {
+ if (!color || color === 'transparent') return true;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!m) return true;
+ return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
}
-function getAP(id) {
- return ANTIPATTERNS.find(a => a.id === id);
+function parseRgb(color) {
+ if (!color || color === 'transparent') return null;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!m) return null;
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
-function finding(id, filePath, snippet, line = 0) {
- const ap = getAP(id);
- return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
+function relativeLuminance({ r, g, b }) {
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
+ );
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
-// Color utilities imported from core
+function contrastRatio(c1, c2) {
+ const l1 = relativeLuminance(c1);
+ const l2 = relativeLuminance(c2);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+}
-/**
- * Resolve the effective background color for an element by walking up ancestors.
- * Returns { r, g, b } or { r: 255, g: 255, b: 255 } as fallback (white).
- */
-function resolveBackground(el, window) {
+function hasChroma(c, threshold = 30) {
+ if (!c) return false;
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
+}
+
+function getHue(c) {
+ if (!c) return 0;
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ if (max === min) return 0;
+ const d = max - min;
+ let h;
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+ else if (max === g) h = ((b - r) / d + 2) / 6;
+ else h = ((r - g) / d + 4) / 6;
+ return Math.round(h * 360);
+}
+
+function colorToHex(c) {
+ if (!c) return '?';
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+// ─── Section 3: Pure Detection ──────────────────────────────────────────────
+
+function checkBorders(tag, widths, colors, radius) {
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+
+ for (const side of sides) {
+ const w = widths[side];
+ if (w < 1 || isNeutralColor(colors[side])) continue;
+
+ const otherSides = sides.filter(s => s !== side);
+ const maxOther = Math.max(...otherSides.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` });
+ }
+ }
+
+ return findings;
+}
+
+function checkColors(opts) {
+ const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+
+ // Pure black background
+ if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
+ findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
+ }
+
+ if (hasDirectText && textColor) {
+ // Gray on colored background
+ const textLum = relativeLuminance(textColor);
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
+ if (isGray && hasChroma(effectiveBg, 40)) {
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
+ }
+
+ // Low contrast (WCAG AA)
+ const ratio = contrastRatio(textColor, effectiveBg);
+ const isHeading = ['h1', 'h2', 'h3'].includes(tag);
+ const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
+ const threshold = isLargeText ? 3.0 : 4.5;
+ if (ratio < threshold) {
+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
+ }
+
+ // AI palette: purple/violet on headings
+ if (hasChroma(textColor, 50)) {
+ const hue = getHue(textColor);
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
+ }
+ }
+ }
+
+ // Gradient text
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
+ }
+
+ // Tailwind class checks
+ if (classList) {
+ if (/\bbg-black\b/.test(classList)) {
+ findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
+ }
+
+ const grayMatch = classList.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
+ const colorBgMatch = classList.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
+ if (grayMatch && colorBgMatch) {
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
+ }
+
+ if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
+ }
+
+ const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
+ }
+
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
+ if (!hasShadow && !hasBorder) return false;
+ return hasRadius || hasBg;
+}
+
+// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
+
+function resolveBackground(el, win) {
let current = el;
while (current && current.nodeType === 1) {
- const style = window.getComputedStyle(current);
- // Try backgroundColor first, then fall back to parsing the inline background shorthand
- // (jsdom doesn't decompose background shorthand to backgroundColor reliably)
+ const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
let bg = parseRgb(style.backgroundColor);
- if (!bg || bg.a < 0.1) {
- // jsdom doesn't reliably decompose background shorthand — parse raw style attr
+ if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
+ // jsdom doesn't decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
@@ -75,17 +315,65 @@ function resolveBackground(el, window) {
}
}
if (bg && bg.a > 0.1) {
- if (bg.a >= 0.5) return bg;
+ if (IS_BROWSER || bg.a >= 0.5) return bg;
}
current = current.parentElement;
}
- return { r: 255, g: 255, b: 255 }; // default to white
+ return { r: 255, g: 255, b: 255 };
+}
+
+// ─── Section 5: Element Adapters ────────────────────────────────────────────
+
+// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
+
+function checkElementBordersDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 20 || rect.height < 20) return [];
+ const style = getComputedStyle(el);
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
+}
+
+function checkElementColorsDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 10 || rect.height < 10) return [];
+ const style = getComputedStyle(el);
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
+ return checkColors({
+ tag,
+ textColor: parseRgb(style.color),
+ bgColor: parseRgb(style.backgroundColor),
+ effectiveBg: resolveBackground(el),
+ fontSize: parseFloat(style.fontSize) || 16,
+ fontWeight: parseInt(style.fontWeight) || 400,
+ hasDirectText,
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
+ bgImage: style.backgroundImage || '',
+ classList: el.getAttribute('class') || '',
+ });
+}
+
+// Node adapters — take pre-extracted jsdom computed style
+
+function checkElementBorders(tag, style) {
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
}
-/**
- * Extract color data from element/style and delegate to core.checkColors.
- * Keeps jsdom-specific resolveBackground here.
- */
function checkElementColors(el, style, tag, window) {
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
@@ -104,27 +392,12 @@ function checkElementColors(el, style, tag, window) {
});
}
-/**
- * Extract border data from computed style and delegate to core.checkBorders.
- */
-function checkElementBorders(tag, style) {
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {}, colors = {};
- for (const s of sides) {
- widths[s] = parseFloat(style[`border${s}Width`]) || 0;
- colors[s] = style[`border${s}Color`] || '';
- }
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
-}
+// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
-/**
- * Page-level typography checks using the document/window API.
- * Returns array of { id, snippet } findings.
- */
-function checkPageTypography(document, window) {
+// Browser page-level checks — use document/getComputedStyle globals
+
+function checkTypography() {
const findings = [];
-
- // --- Overused fonts ---
const fonts = new Set();
const overusedFound = new Set();
@@ -145,8 +418,110 @@ function checkPageTypography(document, window) {
}
}
+ const html = document.documentElement.outerHTML;
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
+ let m;
+ while ((m = gfRe.exec(html)) !== null) {
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
+ fonts.add(f);
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
+ }
+ }
+
+ for (const font of overusedFound) {
+ findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
+ }
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
+ findings.push({ type: 'single-font', detail: `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({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
+ const style = getComputedStyle(el);
+ const cls = el.getAttribute('class') || '';
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
+ const hasBorder = /\bborder\b/.test(cls);
+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
+}
+
+function checkLayout() {
+ const findings = [];
+ const flaggedEls = new Set();
+
+ for (const el of document.querySelectorAll('*')) {
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
+ const cls = el.getAttribute('class') || '';
+ const style = getComputedStyle(el);
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
+ if ((el.textContent?.trim().length || 0) < 10) continue;
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 50 || rect.height < 30) continue;
+
+ let parent = el.parentElement;
+ while (parent) {
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
+ parent = parent.parentElement;
+ }
+ }
+
+ for (const el of flaggedEls) {
+ let isAncestor = false;
+ for (const other of flaggedEls) {
+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
+ }
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
+ }
+
+ return findings;
+}
+
+// Node page-level checks — take document/window as parameters
+
+function checkPageTypography(doc, win) {
+ const findings = [];
+
+ const fonts = new Set();
+ const overusedFound = new Set();
+
+ for (const sheet of doc.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 html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
@@ -173,21 +548,21 @@ function checkPageTypography(document, window) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
- // --- Single font ---
+ // Single font
if (fonts.size === 1) {
- const els = document.querySelectorAll('*');
+ const els = doc.querySelectorAll('*');
if (els.length >= 20) {
findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` });
}
}
- // --- Flat type hierarchy ---
+ // 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');
+ const textEls = doc.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);
+ const fontSize = parseFloat(win.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 (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
@@ -197,37 +572,34 @@ function checkPageTypography(document, window) {
}
}
- // --- Pure black background (regex on raw HTML — only flag #000 as background, not text) ---
+ // Pure black background (regex on raw HTML)
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
- // --- AI color palette: purple/violet in raw CSS ---
- // Very conservative — only flag vivid purple in prominent contexts
+ // AI color palette: purple/violet in raw CSS
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
- // Check if used on text (not just borders or backgrounds)
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(html)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
}
}
- // --- Gradient text (regex on raw HTML — jsdom doesn't compute background-clip) ---
+ // Gradient text (regex on raw HTML — jsdom doesn't compute background-clip)
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
- // Check nearby context for gradient
const start = Math.max(0, gm.index - 200);
const context = html.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- break; // one finding is enough
+ break;
}
}
- // Also check Tailwind gradient text
+ // Tailwind gradient text
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
@@ -235,16 +607,11 @@ function checkPageTypography(document, window) {
return findings;
}
-/**
- * Check if an element looks like a "card". Extracts signals from computed
- * styles, raw inline style (jsdom workaround), and Tailwind classes,
- * then delegates to core.isCardLikeFromProps.
- */
-function isCardLike(el, window) {
+function isCardLike(el, win) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
- const style = window.getComputedStyle(el);
+ const style = win.getComputedStyle(el);
const rawStyle = el.getAttribute?.('style') || '';
const cls = el.getAttribute?.('class') || '';
@@ -259,18 +626,14 @@ function isCardLike(el, window) {
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
}
-/**
- * Page-level layout checks.
- * Returns array of { id, snippet } findings.
- */
-function checkPageLayout(document, window) {
+function checkPageLayout(doc, win) {
const findings = [];
- // --- Nested cards ---
- const allEls = document.querySelectorAll('*');
+ // Nested cards
+ const allEls = doc.querySelectorAll('*');
const flaggedEls = new Set();
for (const el of allEls) {
- if (!isCardLike(el, window)) continue;
+ if (!isCardLike(el, win)) continue;
if (flaggedEls.has(el)) continue;
const tag = el.tagName.toLowerCase();
@@ -285,7 +648,7 @@ function checkPageLayout(document, window) {
// Walk up to find card-like ancestor
let parent = el.parentElement;
while (parent) {
- if (isCardLike(parent, window)) {
+ if (isCardLike(parent, win)) {
flaggedEls.add(el);
break;
}
@@ -293,7 +656,7 @@ function checkPageLayout(document, window) {
}
}
- // Only report innermost nested cards — remove any flagged el that is an ancestor of another
+ // Only report innermost nested cards
for (const el of flaggedEls) {
let isAncestorOfFlagged = false;
for (const other of flaggedEls) {
@@ -307,37 +670,30 @@ function checkPageLayout(document, window) {
}
}
-
- // --- Monotonous spacing ---
- // Regex on raw HTML — jsdom doesn't compute inline px spacing reliably
+ // Monotonous spacing (regex on raw HTML)
const spacingValues = [];
- const html = document.documentElement?.outerHTML || '';
+ const html = doc.documentElement?.outerHTML || '';
- // CSS inline: padding/margin with px values
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
- // CSS gap
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
- // Tailwind spacing classes
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
- // rem values (convert at 16px base)
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
- // Round to nearest 4px to group similar values (e.g., 15px and 16px are effectively the same)
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
@@ -345,7 +701,6 @@ function checkPageLayout(document, window) {
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
- // Flag if the dominant spacing value is used > 60% of the time with few distinct values
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
@@ -355,10 +710,8 @@ function checkPageLayout(document, window) {
}
}
- // --- Everything centered ---
- // Check inline styles and Tailwind classes for text-align: center
- // Also walk up ancestors for inherited centering
- const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
+ // Everything centered
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
let centeredCount = 0;
let totalText = 0;
for (const el of textEls) {
@@ -366,7 +719,6 @@ function checkPageLayout(document, window) {
if (!hasDirectText) continue;
totalText++;
- // Check element and ancestors for centering
let cur = el;
let isCentered = false;
while (cur && cur.nodeType === 1) {
@@ -376,7 +728,6 @@ function checkPageLayout(document, window) {
isCentered = true;
break;
}
- // Stop at body
if (cur.tagName === 'BODY') break;
cur = cur.parentElement;
}
@@ -393,6 +744,185 @@ function checkPageLayout(document, window) {
return findings;
}
+// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
+
+if (IS_BROWSER) {
+ const LABEL_BG = 'oklch(55% 0.25 350)';
+ const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
+
+ const overlays = [];
+ const TYPE_LABELS = {};
+ for (const ap of ANTIPATTERNS) {
+ TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
+ }
+
+ const highlight = function(el, findings) {
+ const rect = el.getBoundingClientRect();
+ const outline = document.createElement('div');
+ outline.className = 'impeccable-overlay';
+ Object.assign(outline.style, {
+ position: 'absolute',
+ top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
+ border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
+ pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
+ });
+
+ const label = document.createElement('div');
+ label.className = 'impeccable-label';
+ label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
+ Object.assign(label.style, {
+ position: 'absolute', top: '-20px', left: '0',
+ background: LABEL_BG, color: 'white',
+ fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
+ padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', letterSpacing: '0.02em',
+ });
+ outline.appendChild(label);
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'impeccable-tooltip';
+ tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('
');
+ Object.assign(tooltip.style, {
+ position: 'absolute', bottom: '-28px', left: '0',
+ background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
+ fontSize: '11px', fontFamily: 'ui-monospace, monospace',
+ padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', display: 'none', zIndex: '100000',
+ });
+ outline.appendChild(tooltip);
+
+ outline.addEventListener('mouseenter', () => {
+ outline.style.pointerEvents = 'auto';
+ tooltip.style.display = 'block';
+ outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
+ });
+ outline.addEventListener('mouseleave', () => {
+ outline.style.pointerEvents = 'none';
+ tooltip.style.display = 'none';
+ outline.style.background = 'none';
+ });
+
+ document.body.appendChild(outline);
+ overlays.push(outline);
+ };
+
+ const showPageBanner = function(findings) {
+ if (!findings.length) return;
+ const banner = document.createElement('div');
+ banner.className = 'impeccable-overlay';
+ Object.assign(banner.style, {
+ position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
+ background: LABEL_BG, color: 'white',
+ fontFamily: 'system-ui, sans-serif', fontSize: '13px',
+ padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
+ gap: '12px', alignItems: 'center', pointerEvents: 'auto',
+ });
+ for (const f of findings) {
+ const tag = document.createElement('span');
+ tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
+ Object.assign(tag.style, {
+ background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
+ borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
+ });
+ banner.appendChild(tag);
+ }
+ const close = document.createElement('button');
+ close.textContent = '\u00d7';
+ Object.assign(close.style, {
+ marginLeft: 'auto', background: 'none', border: 'none',
+ color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
+ });
+ close.addEventListener('click', () => banner.remove());
+ banner.appendChild(close);
+ document.body.appendChild(banner);
+ overlays.push(banner);
+ };
+
+ const printSummary = function(allFindings) {
+ if (allFindings.length === 0) {
+ console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
+ return;
+ }
+ console.group(
+ `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
+ 'color: oklch(60% 0.25 350); font-weight: bold'
+ );
+ for (const { el, findings } of allFindings) {
+ for (const f of findings) {
+ console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
+ 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
+ }
+ }
+ console.groupEnd();
+ };
+
+ const scan = function() {
+ for (const o of overlays) o.remove();
+ overlays.length = 0;
+ const allFindings = [];
+
+ for (const el of document.querySelectorAll('*')) {
+ if (el.classList.contains('impeccable-overlay') ||
+ el.classList.contains('impeccable-label') ||
+ el.classList.contains('impeccable-tooltip')) continue;
+
+ const findings = [
+ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ];
+
+ if (findings.length > 0) {
+ highlight(el, findings);
+ allFindings.push({ el, findings });
+ }
+ }
+
+ const typoFindings = checkTypography();
+ if (typoFindings.length > 0) {
+ showPageBanner(typoFindings);
+ allFindings.push({ el: document.body, findings: typoFindings });
+ }
+
+ const layoutFindings = checkLayout();
+ for (const f of layoutFindings) {
+ const el = f.el || document.body;
+ delete f.el;
+ highlight(el, [f]);
+ allFindings.push({ el, findings: [f] });
+ }
+
+ printSummary(allFindings);
+ return allFindings;
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
+ } else {
+ setTimeout(scan, 100);
+ }
+
+ window.impeccableScan = scan;
+}
+
+// ─── Section 8: Node Engine ─────────────────────────────────────────────────
+// @browser-strip-start
+
+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 };
+}
+
+/** Check if content looks like a full page (not a component/partial) */
+function isFullPage(content) {
+ const stripped = content.replace(//g, '');
+ return /]|]/i.test(stripped);
+}
+
// ---------------------------------------------------------------------------
// jsdom detection (default for HTML files)
// ---------------------------------------------------------------------------
@@ -402,7 +932,6 @@ async function detectHtml(filePath) {
try {
({ JSDOM } = await import('jsdom'));
} catch {
- // jsdom not available — fall back to regex
const content = fs.readFileSync(filePath, 'utf-8');
return detectText(content, filePath);
}
@@ -480,7 +1009,6 @@ async function detectUrl(url) {
}
// Read the browser detection script — reuse it instead of reimplementing
- // The generated browser script lives alongside this file after build
const browserScriptPath = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'detect-antipatterns-browser.js'
@@ -502,7 +1030,6 @@ async function detectUrl(url) {
const results = await page.evaluate(() => {
if (!window.impeccableScan) return [];
const allFindings = window.impeccableScan();
- // Flatten: each entry has { el, findings: [{type, detail}] }
return allFindings.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
);
@@ -516,7 +1043,6 @@ async function detectUrl(url) {
// 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);
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);
@@ -678,7 +1204,6 @@ const REGEX_ANALYZERS = [
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
- // Check lines that have text content elements
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
@@ -689,9 +1214,6 @@ const REGEX_ANALYZERS = [
},
];
-/**
- * Regex-based detection for non-HTML files or --fast mode.
- */
function detectText(content, filePath) {
const findings = [];
const lines = content.split('\n');
@@ -703,7 +1225,7 @@ function detectText(content, filePath) {
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));
+ findings.push(finding(matcher.id, filePath, matcher.fmt(m, line), i + 1));
}
}
}
@@ -876,11 +1398,18 @@ async function main() {
}
// ---------------------------------------------------------------------------
-// Entry point + exports
+// Entry point
// ---------------------------------------------------------------------------
-const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs');
-if (isMainModule) main();
+if (!IS_BROWSER) {
+ const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs');
+ if (isMainModule) main();
+}
+
+// @browser-strip-end
+
+// ─── Section 9: Exports ─────────────────────────────────────────────────────
+// @browser-strip-start
export {
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
@@ -888,3 +1417,5 @@ export {
detectHtml, detectUrl, detectText,
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
};
+
+// @browser-strip-end
diff --git a/scripts/build-browser-detector.js b/scripts/build-browser-detector.js
index e7d3d96e2..cf0bf4c91 100644
--- a/scripts/build-browser-detector.js
+++ b/scripts/build-browser-detector.js
@@ -1,10 +1,8 @@
#!/usr/bin/env node
/**
- * Generates public/js/detect-antipatterns-browser.js by:
- * 1. Reading the core module (shared constants + pure functions)
- * 2. Reading the browser wrapper template
- * 3. Injecting the core into the wrapper's IIFE
+ * Generates .claude/skills/critique/scripts/detect-antipatterns-browser.js
+ * by stripping Node-specific sections from the universal source and wrapping in an IIFE.
*
* Run: node scripts/build-browser-detector.js
*/
@@ -16,22 +14,31 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
-const CORE_PATH = path.join(ROOT, 'source/skills/critique/scripts/detect-antipatterns-core.mjs');
-const WRAPPER_PATH = path.join(ROOT, 'source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js');
-const OUTPUT_PATH = path.join(ROOT, '.claude/skills/critique/scripts/detect-antipatterns-browser.js');
+const SOURCE = path.join(ROOT, 'source/skills/critique/scripts/detect-antipatterns.mjs');
+const OUTPUT = path.join(ROOT, '.claude/skills/critique/scripts/detect-antipatterns-browser.js');
-// Read and strip exports from core
-let core = fs.readFileSync(CORE_PATH, 'utf-8');
-core = core
- .replace(/^export\s+/gm, '') // Remove 'export' keywords
- .replace(/^\/\*\*[\s\S]*?\*\/\n/m, '') // Remove file-level JSDoc
- .trim();
+let code = fs.readFileSync(SOURCE, 'utf-8');
-// Read the browser wrapper
-const wrapper = fs.readFileSync(WRAPPER_PATH, 'utf-8');
+// Strip shebang
+code = code.replace(/^#!.*\n/, '');
+// Strip sections between @browser-strip-start / @browser-strip-end markers
+code = code.replace(/^\/\/ @browser-strip-start\n[\s\S]*?^\/\/ @browser-strip-end\n?/gm, '');
+// Set IS_BROWSER = true (dead-code eliminates Node paths)
+code = code.replace(/^const IS_BROWSER = .*$/m, 'const IS_BROWSER = true;');
-// Inject core into the wrapper at the marker
-const output = wrapper.replace('// {{CORE_INJECTION_POINT}}', core);
+const output = `/**
+ * Anti-Pattern Browser Detector for Impeccable
+ * GENERATED — do not edit. Source: detect-antipatterns.mjs
+ * Rebuild: node scripts/build-browser-detector.js
+ *
+ * Usage:
+ * Re-scan: window.impeccableScan()
+ */
+(function () {
+if (typeof window === 'undefined') return;
+${code}
+})();
+`;
-fs.writeFileSync(OUTPUT_PATH, output);
-console.log(`✓ Generated ${path.relative(ROOT, OUTPUT_PATH)} (${(output.length / 1024).toFixed(1)} KB)`);
+fs.writeFileSync(OUTPUT, output);
+console.log(`\u2713 Generated ${path.relative(ROOT, OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
diff --git a/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js b/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
deleted file mode 100644
index c0dd172f9..000000000
--- a/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
+++ /dev/null
@@ -1,335 +0,0 @@
-/**
- * Anti-Pattern Browser Detector for Impeccable
- * GENERATED — do not edit. Source: detect-antipatterns-core.mjs + this wrapper.
- * Rebuild: node scripts/build-browser-detector.js
- *
- * Usage:
- * Re-scan: window.impeccableScan()
- */
-(function () {
- if (typeof window === 'undefined') return;
-
- const LABEL_BG = 'oklch(55% 0.25 350)';
- const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
-
- // ===========================================================================
- // Core detection logic (injected from detect-antipatterns-core.mjs at build)
- // ===========================================================================
-
- // {{CORE_INJECTION_POINT}}
-
- // ===========================================================================
- // Browser-specific: DOM element adapters
- // ===========================================================================
-
- function resolveBackground(el) {
- let current = el;
- while (current && current.nodeType === 1) {
- const bg = parseRgb(getComputedStyle(current).backgroundColor);
- if (bg && bg.a > 0.1) return bg;
- current = current.parentElement;
- }
- return { r: 255, g: 255, b: 255 };
- }
-
- function checkElementBordersDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag)) return [];
- const rect = el.getBoundingClientRect();
- if (rect.width < 20 || rect.height < 20) return [];
- const style = getComputedStyle(el);
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {}, colors = {};
- for (const s of sides) {
- widths[s] = parseFloat(style[`border${s}Width`]) || 0;
- colors[s] = style[`border${s}Color`] || '';
- }
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
- }
-
- function checkElementColorsDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag)) return [];
- const rect = el.getBoundingClientRect();
- if (rect.width < 10 || rect.height < 10) return [];
- const style = getComputedStyle(el);
- const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
- return checkColors({
- tag,
- textColor: parseRgb(style.color),
- bgColor: parseRgb(style.backgroundColor),
- effectiveBg: resolveBackground(el),
- fontSize: parseFloat(style.fontSize) || 16,
- fontWeight: parseInt(style.fontWeight) || 400,
- hasDirectText,
- bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
- bgImage: style.backgroundImage || '',
- classList: el.getAttribute('class') || '',
- });
- }
-
- // ===========================================================================
- // Browser-specific: Page-level checks
- // ===========================================================================
-
- function checkTypography() {
- const findings = [];
- 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);
- }
- }
- }
-
- const html = document.documentElement.outerHTML;
- const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
- let m;
- while ((m = gfRe.exec(html)) !== null) {
- for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
- fonts.add(f);
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
- }
- }
-
- for (const font of overusedFound) {
- findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
- }
- if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
- findings.push({ type: 'single-font', detail: `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({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
- }
- }
-
- return findings;
- }
-
- function isCardLikeDOM(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
- const style = getComputedStyle(el);
- const cls = el.getAttribute('class') || '';
- const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
- const hasBorder = /\bborder\b/.test(cls);
- const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
- const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
- return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
- }
-
- function checkLayout() {
- const findings = [];
- const flaggedEls = new Set();
-
- for (const el of document.querySelectorAll('*')) {
- if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
- const cls = el.getAttribute('class') || '';
- const style = getComputedStyle(el);
- if (style.position === 'absolute' || style.position === 'fixed') continue;
- if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
- if ((el.textContent?.trim().length || 0) < 10) continue;
- const rect = el.getBoundingClientRect();
- if (rect.width < 50 || rect.height < 30) continue;
-
- let parent = el.parentElement;
- while (parent) {
- if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
- parent = parent.parentElement;
- }
- }
-
- for (const el of flaggedEls) {
- let isAncestor = false;
- for (const other of flaggedEls) {
- if (other !== el && el.contains(other)) { isAncestor = true; break; }
- }
- if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
- }
-
- return findings;
- }
-
- // ===========================================================================
- // Highlighting & UI
- // ===========================================================================
-
- const overlays = [];
- const TYPE_LABELS = {};
- for (const ap of ANTIPATTERNS) {
- TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
- }
-
- function highlight(el, findings) {
- const rect = el.getBoundingClientRect();
- const outline = document.createElement('div');
- outline.className = 'impeccable-overlay';
- Object.assign(outline.style, {
- position: 'absolute',
- top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
- width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
- border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
- pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
- });
-
- const label = document.createElement('div');
- label.className = 'impeccable-label';
- label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
- Object.assign(label.style, {
- position: 'absolute', top: '-20px', left: '0',
- background: LABEL_BG, color: 'white',
- fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
- padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
- lineHeight: '16px', letterSpacing: '0.02em',
- });
- outline.appendChild(label);
-
- const tooltip = document.createElement('div');
- tooltip.className = 'impeccable-tooltip';
- tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('
');
- Object.assign(tooltip.style, {
- position: 'absolute', bottom: '-28px', left: '0',
- background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
- fontSize: '11px', fontFamily: 'ui-monospace, monospace',
- padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
- lineHeight: '16px', display: 'none', zIndex: '100000',
- });
- outline.appendChild(tooltip);
-
- outline.addEventListener('mouseenter', () => {
- outline.style.pointerEvents = 'auto';
- tooltip.style.display = 'block';
- outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
- });
- outline.addEventListener('mouseleave', () => {
- outline.style.pointerEvents = 'none';
- tooltip.style.display = 'none';
- outline.style.background = 'none';
- });
-
- document.body.appendChild(outline);
- overlays.push(outline);
- }
-
- function showPageBanner(findings) {
- if (!findings.length) return;
- const banner = document.createElement('div');
- banner.className = 'impeccable-overlay';
- Object.assign(banner.style, {
- position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
- background: LABEL_BG, color: 'white',
- fontFamily: 'system-ui, sans-serif', fontSize: '13px',
- padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
- gap: '12px', alignItems: 'center', pointerEvents: 'auto',
- });
- for (const f of findings) {
- const tag = document.createElement('span');
- tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
- Object.assign(tag.style, {
- background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
- borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
- });
- banner.appendChild(tag);
- }
- const close = document.createElement('button');
- close.textContent = '\u00d7';
- Object.assign(close.style, {
- marginLeft: 'auto', background: 'none', border: 'none',
- color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
- });
- close.addEventListener('click', () => banner.remove());
- banner.appendChild(close);
- document.body.appendChild(banner);
- overlays.push(banner);
- }
-
- function printSummary(allFindings) {
- if (allFindings.length === 0) {
- console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
- return;
- }
- console.group(
- `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
- 'color: oklch(60% 0.25 350); font-weight: bold'
- );
- for (const { el, findings } of allFindings) {
- for (const f of findings) {
- console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
- 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
- }
- }
- console.groupEnd();
- }
-
- // ===========================================================================
- // Main scan
- // ===========================================================================
-
- function scan() {
- for (const o of overlays) o.remove();
- overlays.length = 0;
- const allFindings = [];
-
- for (const el of document.querySelectorAll('*')) {
- if (el.classList.contains('impeccable-overlay') ||
- el.classList.contains('impeccable-label') ||
- el.classList.contains('impeccable-tooltip')) continue;
-
- const findings = [
- ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
- ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
- ];
-
- if (findings.length > 0) {
- highlight(el, findings);
- allFindings.push({ el, findings });
- }
- }
-
- const typoFindings = checkTypography();
- if (typoFindings.length > 0) {
- showPageBanner(typoFindings);
- allFindings.push({ el: document.body, findings: typoFindings });
- }
-
- const layoutFindings = checkLayout();
- for (const f of layoutFindings) {
- const el = f.el || document.body;
- delete f.el;
- highlight(el, [f]);
- allFindings.push({ el, findings: [f] });
- }
-
- printSummary(allFindings);
- return allFindings;
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
- } else {
- setTimeout(scan, 100);
- }
-
- window.impeccableScan = scan;
-})();
diff --git a/source/skills/critique/scripts/detect-antipatterns-core.mjs b/source/skills/critique/scripts/detect-antipatterns-core.mjs
deleted file mode 100644
index ef1982182..000000000
--- a/source/skills/critique/scripts/detect-antipatterns-core.mjs
+++ /dev/null
@@ -1,297 +0,0 @@
-/**
- * Anti-Pattern Detection Core — shared between CLI and browser.
- *
- * All functions here are pure (no DOM/Node dependencies) and work in both
- * jsdom and real browser environments. They take primitive/data arguments,
- * not raw DOM elements.
- */
-
-// ---------------------------------------------------------------------------
-// Constants
-// ---------------------------------------------------------------------------
-
-export 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',
-]);
-
-export const OVERUSED_FONTS = new Set([
- 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
-]);
-
-export 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',
-]);
-
-export 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).',
- },
- {
- id: 'pure-black-white',
- name: 'Pure black background',
- description:
- 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
- },
- {
- id: 'gray-on-color',
- name: 'Gray text on colored background',
- description:
- 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
- },
- {
- id: 'low-contrast',
- name: 'Low contrast text',
- description:
- 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
- },
- {
- id: 'gradient-text',
- name: 'Gradient text',
- description:
- 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
- },
- {
- id: 'ai-color-palette',
- name: 'AI color palette',
- description:
- 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
- },
- {
- id: 'nested-cards',
- name: 'Nested cards',
- description:
- 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
- },
- {
- id: 'monotonous-spacing',
- name: 'Monotonous spacing',
- description:
- 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
- },
- {
- id: 'everything-centered',
- name: 'Everything centered',
- description:
- 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
- },
-];
-
-// ---------------------------------------------------------------------------
-// Color utilities
-// ---------------------------------------------------------------------------
-
-export function isNeutralColor(color) {
- if (!color || color === 'transparent') return true;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (!m) return true;
- return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
-}
-
-export function parseRgb(color) {
- if (!color || color === 'transparent') return null;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
- if (!m) return null;
- return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
-}
-
-export function relativeLuminance({ r, g, b }) {
- const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
- );
- return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
-}
-
-export function contrastRatio(c1, c2) {
- const l1 = relativeLuminance(c1);
- const l2 = relativeLuminance(c2);
- return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
-}
-
-export function hasChroma(c, threshold = 30) {
- if (!c) return false;
- return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
-}
-
-export function getHue(c) {
- if (!c) return 0;
- const r = c.r / 255, g = c.g / 255, b = c.b / 255;
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
- if (max === min) return 0;
- const d = max - min;
- let h;
- if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
- else if (max === g) h = ((b - r) / d + 2) / 6;
- else h = ((r - g) / d + 4) / 6;
- return Math.round(h * 360);
-}
-
-export function colorToHex(c) {
- if (!c) return '?';
- return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
-}
-
-// ---------------------------------------------------------------------------
-// Element-level detection (pure: takes data, not DOM elements)
-// ---------------------------------------------------------------------------
-
-/**
- * Check border widths/colors/radius for side-tab and accent-on-rounded patterns.
- * @param {string} tag Element tag name (lowercase)
- * @param {{ Top: number, Right: number, Bottom: number, Left: number }} widths Border widths in px
- * @param {{ Top: string, Right: string, Bottom: string, Left: string }} colors Border colors as rgb() strings
- * @param {number} radius Border radius in px
- * @returns {Array<{ id: string, snippet: string }>}
- */
-export function checkBorders(tag, widths, colors, radius) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
-
- for (const side of sides) {
- const w = widths[side];
- if (w < 1 || isNeutralColor(colors[side])) continue;
-
- const otherSides = sides.filter(s => s !== side);
- const maxOther = Math.max(...otherSides.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` });
- }
- }
-
- return findings;
-}
-
-/**
- * Check colors for anti-patterns given pre-extracted data.
- * @param {object} opts
- * @param {string} opts.tag
- * @param {object|null} opts.textColor Parsed RGB
- * @param {object|null} opts.bgColor Parsed RGB (direct background)
- * @param {object} opts.effectiveBg Resolved background (walked ancestors)
- * @param {number} opts.fontSize In px
- * @param {number} opts.fontWeight
- * @param {boolean} opts.hasDirectText
- * @param {string} opts.bgClip Computed background-clip value
- * @param {string} opts.bgImage Computed background-image value
- * @param {string} opts.classList Raw class attribute string
- * @returns {Array<{ id: string, snippet: string }>}
- */
-export function checkColors(opts) {
- const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
- // Pure black background
- if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
- findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
- }
-
- if (hasDirectText && textColor) {
- // Gray on colored background
- const textLum = relativeLuminance(textColor);
- const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
- if (isGray && hasChroma(effectiveBg, 40)) {
- findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
- }
-
- // Low contrast (WCAG AA)
- const ratio = contrastRatio(textColor, effectiveBg);
- const isHeading = ['h1', 'h2', 'h3'].includes(tag);
- const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
- const threshold = isLargeText ? 3.0 : 4.5;
- if (ratio < threshold) {
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
- }
-
- // AI palette: purple/violet on headings
- if (hasChroma(textColor, 50)) {
- const hue = getHue(textColor);
- if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
- findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
- }
- }
- }
-
- // Gradient text
- if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- }
-
- // Tailwind class checks
- if (classList) {
- if (/\bbg-black\b/.test(classList)) {
- findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
- }
-
- const grayMatch = classList.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
- const colorBgMatch = classList.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
- if (grayMatch && colorBgMatch) {
- findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
- }
-
- if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
- findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
- }
-
- const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
- if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
- findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
- }
-
- if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
- }
- }
-
- return findings;
-}
-
-/**
- * Check if an element's properties make it "card-like".
- */
-export function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
- if (!hasShadow && !hasBorder) return false;
- return hasRadius || hasBg;
-}
diff --git a/source/skills/critique/scripts/detect-antipatterns.mjs b/source/skills/critique/scripts/detect-antipatterns.mjs
index 5e7d91c65..4a45d8477 100644
--- a/source/skills/critique/scripts/detect-antipatterns.mjs
+++ b/source/skills/critique/scripts/detect-antipatterns.mjs
@@ -3,61 +3,301 @@
/**
* Anti-Pattern Detector for Impeccable
*
- * 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.
+ * Universal file — auto-detects environment (browser vs Node) and adapts.
*
- * Usage:
+ * Node usage:
* 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
*
+ * Browser usage:
+ *
+ * Re-scan: window.impeccableScan()
+ *
* Exit codes: 0 = clean, 2 = findings
*/
-import fs from 'fs';
-import path from 'path';
-import {
- SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, ANTIPATTERNS,
- isNeutralColor, parseRgb, relativeLuminance, contrastRatio,
- hasChroma, getHue, colorToHex,
- checkBorders, checkColors, isCardLikeFromProps,
-} from './detect-antipatterns-core.mjs';
+// ─── Environment ────────────────────────────────────────────────────────────
-// ANTIPATTERNS, constants, and color utilities imported from core
+const IS_BROWSER = typeof window !== 'undefined';
+const IS_NODE = !IS_BROWSER;
-/** Check if content looks like a full page (not a component/partial) */
-function isFullPage(content) {
- // Strip HTML comments before checking — they might mention / in prose
- const stripped = content.replace(//g, '');
- return /]|]/i.test(stripped);
+// @browser-strip-start
+let fs, path;
+if (!IS_BROWSER) {
+ fs = (await import('node:fs')).default;
+ path = (await import('node:path')).default;
+}
+// @browser-strip-end
+
+// ─── Section 1: 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',
+]);
+
+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).',
+ },
+ {
+ id: 'pure-black-white',
+ name: 'Pure black background',
+ description:
+ 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
+ },
+ {
+ id: 'gray-on-color',
+ name: 'Gray text on colored background',
+ description:
+ 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
+ },
+ {
+ id: 'low-contrast',
+ name: 'Low contrast text',
+ description:
+ 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
+ },
+ {
+ id: 'gradient-text',
+ name: 'Gradient text',
+ description:
+ 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
+ },
+ {
+ id: 'ai-color-palette',
+ name: 'AI color palette',
+ description:
+ 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
+ },
+ {
+ id: 'nested-cards',
+ name: 'Nested cards',
+ description:
+ 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
+ },
+ {
+ id: 'monotonous-spacing',
+ name: 'Monotonous spacing',
+ description:
+ 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
+ },
+ {
+ id: 'everything-centered',
+ name: 'Everything centered',
+ description:
+ 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
+ },
+];
+
+// ─── Section 2: Color Utilities ─────────────────────────────────────────────
+
+function isNeutralColor(color) {
+ if (!color || color === 'transparent') return true;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!m) return true;
+ return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
}
-function getAP(id) {
- return ANTIPATTERNS.find(a => a.id === id);
+function parseRgb(color) {
+ if (!color || color === 'transparent') return null;
+ const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
+ if (!m) return null;
+ return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
-function finding(id, filePath, snippet, line = 0) {
- const ap = getAP(id);
- return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
+function relativeLuminance({ r, g, b }) {
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
+ );
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
-// Color utilities imported from core
+function contrastRatio(c1, c2) {
+ const l1 = relativeLuminance(c1);
+ const l2 = relativeLuminance(c2);
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
+}
-/**
- * Resolve the effective background color for an element by walking up ancestors.
- * Returns { r, g, b } or { r: 255, g: 255, b: 255 } as fallback (white).
- */
-function resolveBackground(el, window) {
+function hasChroma(c, threshold = 30) {
+ if (!c) return false;
+ return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
+}
+
+function getHue(c) {
+ if (!c) return 0;
+ const r = c.r / 255, g = c.g / 255, b = c.b / 255;
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
+ if (max === min) return 0;
+ const d = max - min;
+ let h;
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
+ else if (max === g) h = ((b - r) / d + 2) / 6;
+ else h = ((r - g) / d + 4) / 6;
+ return Math.round(h * 360);
+}
+
+function colorToHex(c) {
+ if (!c) return '?';
+ return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
+}
+
+// ─── Section 3: Pure Detection ──────────────────────────────────────────────
+
+function checkBorders(tag, widths, colors, radius) {
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+
+ for (const side of sides) {
+ const w = widths[side];
+ if (w < 1 || isNeutralColor(colors[side])) continue;
+
+ const otherSides = sides.filter(s => s !== side);
+ const maxOther = Math.max(...otherSides.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` });
+ }
+ }
+
+ return findings;
+}
+
+function checkColors(opts) {
+ const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
+ if (SAFE_TAGS.has(tag)) return [];
+ const findings = [];
+
+ // Pure black background
+ if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
+ findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
+ }
+
+ if (hasDirectText && textColor) {
+ // Gray on colored background
+ const textLum = relativeLuminance(textColor);
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
+ if (isGray && hasChroma(effectiveBg, 40)) {
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
+ }
+
+ // Low contrast (WCAG AA)
+ const ratio = contrastRatio(textColor, effectiveBg);
+ const isHeading = ['h1', 'h2', 'h3'].includes(tag);
+ const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
+ const threshold = isLargeText ? 3.0 : 4.5;
+ if (ratio < threshold) {
+ findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
+ }
+
+ // AI palette: purple/violet on headings
+ if (hasChroma(textColor, 50)) {
+ const hue = getHue(textColor);
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
+ }
+ }
+ }
+
+ // Gradient text
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
+ }
+
+ // Tailwind class checks
+ if (classList) {
+ if (/\bbg-black\b/.test(classList)) {
+ findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
+ }
+
+ const grayMatch = classList.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
+ const colorBgMatch = classList.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
+ if (grayMatch && colorBgMatch) {
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
+ }
+
+ if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
+ }
+
+ const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
+ }
+
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
+ if (!hasShadow && !hasBorder) return false;
+ return hasRadius || hasBg;
+}
+
+// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
+
+function resolveBackground(el, win) {
let current = el;
while (current && current.nodeType === 1) {
- const style = window.getComputedStyle(current);
- // Try backgroundColor first, then fall back to parsing the inline background shorthand
- // (jsdom doesn't decompose background shorthand to backgroundColor reliably)
+ const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
let bg = parseRgb(style.backgroundColor);
- if (!bg || bg.a < 0.1) {
- // jsdom doesn't reliably decompose background shorthand — parse raw style attr
+ if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
+ // jsdom doesn't decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
@@ -75,17 +315,65 @@ function resolveBackground(el, window) {
}
}
if (bg && bg.a > 0.1) {
- if (bg.a >= 0.5) return bg;
+ if (IS_BROWSER || bg.a >= 0.5) return bg;
}
current = current.parentElement;
}
- return { r: 255, g: 255, b: 255 }; // default to white
+ return { r: 255, g: 255, b: 255 };
+}
+
+// ─── Section 5: Element Adapters ────────────────────────────────────────────
+
+// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
+
+function checkElementBordersDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 20 || rect.height < 20) return [];
+ const style = getComputedStyle(el);
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
+}
+
+function checkElementColorsDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag)) return [];
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 10 || rect.height < 10) return [];
+ const style = getComputedStyle(el);
+ const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
+ return checkColors({
+ tag,
+ textColor: parseRgb(style.color),
+ bgColor: parseRgb(style.backgroundColor),
+ effectiveBg: resolveBackground(el),
+ fontSize: parseFloat(style.fontSize) || 16,
+ fontWeight: parseInt(style.fontWeight) || 400,
+ hasDirectText,
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
+ bgImage: style.backgroundImage || '',
+ classList: el.getAttribute('class') || '',
+ });
+}
+
+// Node adapters — take pre-extracted jsdom computed style
+
+function checkElementBorders(tag, style) {
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
+ const widths = {}, colors = {};
+ for (const s of sides) {
+ widths[s] = parseFloat(style[`border${s}Width`]) || 0;
+ colors[s] = style[`border${s}Color`] || '';
+ }
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
}
-/**
- * Extract color data from element/style and delegate to core.checkColors.
- * Keeps jsdom-specific resolveBackground here.
- */
function checkElementColors(el, style, tag, window) {
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
@@ -104,27 +392,12 @@ function checkElementColors(el, style, tag, window) {
});
}
-/**
- * Extract border data from computed style and delegate to core.checkBorders.
- */
-function checkElementBorders(tag, style) {
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {}, colors = {};
- for (const s of sides) {
- widths[s] = parseFloat(style[`border${s}Width`]) || 0;
- colors[s] = style[`border${s}Color`] || '';
- }
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
-}
+// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
-/**
- * Page-level typography checks using the document/window API.
- * Returns array of { id, snippet } findings.
- */
-function checkPageTypography(document, window) {
+// Browser page-level checks — use document/getComputedStyle globals
+
+function checkTypography() {
const findings = [];
-
- // --- Overused fonts ---
const fonts = new Set();
const overusedFound = new Set();
@@ -145,8 +418,110 @@ function checkPageTypography(document, window) {
}
}
+ const html = document.documentElement.outerHTML;
+ const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
+ let m;
+ while ((m = gfRe.exec(html)) !== null) {
+ for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
+ fonts.add(f);
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
+ }
+ }
+
+ for (const font of overusedFound) {
+ findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
+ }
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
+ findings.push({ type: 'single-font', detail: `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({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
+ }
+ }
+
+ return findings;
+}
+
+function isCardLikeDOM(el) {
+ const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
+ const style = getComputedStyle(el);
+ const cls = el.getAttribute('class') || '';
+ const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
+ const hasBorder = /\bborder\b/.test(cls);
+ const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
+ const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
+}
+
+function checkLayout() {
+ const findings = [];
+ const flaggedEls = new Set();
+
+ for (const el of document.querySelectorAll('*')) {
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
+ const cls = el.getAttribute('class') || '';
+ const style = getComputedStyle(el);
+ if (style.position === 'absolute' || style.position === 'fixed') continue;
+ if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
+ if ((el.textContent?.trim().length || 0) < 10) continue;
+ const rect = el.getBoundingClientRect();
+ if (rect.width < 50 || rect.height < 30) continue;
+
+ let parent = el.parentElement;
+ while (parent) {
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
+ parent = parent.parentElement;
+ }
+ }
+
+ for (const el of flaggedEls) {
+ let isAncestor = false;
+ for (const other of flaggedEls) {
+ if (other !== el && el.contains(other)) { isAncestor = true; break; }
+ }
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
+ }
+
+ return findings;
+}
+
+// Node page-level checks — take document/window as parameters
+
+function checkPageTypography(doc, win) {
+ const findings = [];
+
+ const fonts = new Set();
+ const overusedFound = new Set();
+
+ for (const sheet of doc.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 html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
@@ -173,21 +548,21 @@ function checkPageTypography(document, window) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
- // --- Single font ---
+ // Single font
if (fonts.size === 1) {
- const els = document.querySelectorAll('*');
+ const els = doc.querySelectorAll('*');
if (els.length >= 20) {
findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` });
}
}
- // --- Flat type hierarchy ---
+ // 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');
+ const textEls = doc.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);
+ const fontSize = parseFloat(win.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 (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
@@ -197,37 +572,34 @@ function checkPageTypography(document, window) {
}
}
- // --- Pure black background (regex on raw HTML — only flag #000 as background, not text) ---
+ // Pure black background (regex on raw HTML)
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
- // --- AI color palette: purple/violet in raw CSS ---
- // Very conservative — only flag vivid purple in prominent contexts
+ // AI color palette: purple/violet in raw CSS
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
- // Check if used on text (not just borders or backgrounds)
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(html)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
}
}
- // --- Gradient text (regex on raw HTML — jsdom doesn't compute background-clip) ---
+ // Gradient text (regex on raw HTML — jsdom doesn't compute background-clip)
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
- // Check nearby context for gradient
const start = Math.max(0, gm.index - 200);
const context = html.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- break; // one finding is enough
+ break;
}
}
- // Also check Tailwind gradient text
+ // Tailwind gradient text
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
@@ -235,16 +607,11 @@ function checkPageTypography(document, window) {
return findings;
}
-/**
- * Check if an element looks like a "card". Extracts signals from computed
- * styles, raw inline style (jsdom workaround), and Tailwind classes,
- * then delegates to core.isCardLikeFromProps.
- */
-function isCardLike(el, window) {
+function isCardLike(el, win) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
- const style = window.getComputedStyle(el);
+ const style = win.getComputedStyle(el);
const rawStyle = el.getAttribute?.('style') || '';
const cls = el.getAttribute?.('class') || '';
@@ -259,18 +626,14 @@ function isCardLike(el, window) {
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
}
-/**
- * Page-level layout checks.
- * Returns array of { id, snippet } findings.
- */
-function checkPageLayout(document, window) {
+function checkPageLayout(doc, win) {
const findings = [];
- // --- Nested cards ---
- const allEls = document.querySelectorAll('*');
+ // Nested cards
+ const allEls = doc.querySelectorAll('*');
const flaggedEls = new Set();
for (const el of allEls) {
- if (!isCardLike(el, window)) continue;
+ if (!isCardLike(el, win)) continue;
if (flaggedEls.has(el)) continue;
const tag = el.tagName.toLowerCase();
@@ -285,7 +648,7 @@ function checkPageLayout(document, window) {
// Walk up to find card-like ancestor
let parent = el.parentElement;
while (parent) {
- if (isCardLike(parent, window)) {
+ if (isCardLike(parent, win)) {
flaggedEls.add(el);
break;
}
@@ -293,7 +656,7 @@ function checkPageLayout(document, window) {
}
}
- // Only report innermost nested cards — remove any flagged el that is an ancestor of another
+ // Only report innermost nested cards
for (const el of flaggedEls) {
let isAncestorOfFlagged = false;
for (const other of flaggedEls) {
@@ -307,37 +670,30 @@ function checkPageLayout(document, window) {
}
}
-
- // --- Monotonous spacing ---
- // Regex on raw HTML — jsdom doesn't compute inline px spacing reliably
+ // Monotonous spacing (regex on raw HTML)
const spacingValues = [];
- const html = document.documentElement?.outerHTML || '';
+ const html = doc.documentElement?.outerHTML || '';
- // CSS inline: padding/margin with px values
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
- // CSS gap
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
- // Tailwind spacing classes
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
- // rem values (convert at 16px base)
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
- // Round to nearest 4px to group similar values (e.g., 15px and 16px are effectively the same)
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
@@ -345,7 +701,6 @@ function checkPageLayout(document, window) {
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
- // Flag if the dominant spacing value is used > 60% of the time with few distinct values
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
@@ -355,10 +710,8 @@ function checkPageLayout(document, window) {
}
}
- // --- Everything centered ---
- // Check inline styles and Tailwind classes for text-align: center
- // Also walk up ancestors for inherited centering
- const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
+ // Everything centered
+ const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
let centeredCount = 0;
let totalText = 0;
for (const el of textEls) {
@@ -366,7 +719,6 @@ function checkPageLayout(document, window) {
if (!hasDirectText) continue;
totalText++;
- // Check element and ancestors for centering
let cur = el;
let isCentered = false;
while (cur && cur.nodeType === 1) {
@@ -376,7 +728,6 @@ function checkPageLayout(document, window) {
isCentered = true;
break;
}
- // Stop at body
if (cur.tagName === 'BODY') break;
cur = cur.parentElement;
}
@@ -393,6 +744,185 @@ function checkPageLayout(document, window) {
return findings;
}
+// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
+
+if (IS_BROWSER) {
+ const LABEL_BG = 'oklch(55% 0.25 350)';
+ const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
+
+ const overlays = [];
+ const TYPE_LABELS = {};
+ for (const ap of ANTIPATTERNS) {
+ TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
+ }
+
+ const highlight = function(el, findings) {
+ const rect = el.getBoundingClientRect();
+ const outline = document.createElement('div');
+ outline.className = 'impeccable-overlay';
+ Object.assign(outline.style, {
+ position: 'absolute',
+ top: `${rect.top + scrollY - 2}px`, left: `${rect.left + scrollX - 2}px`,
+ width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
+ border: `2px solid ${OUTLINE_COLOR}`, borderRadius: '4px',
+ pointerEvents: 'none', zIndex: '99999', boxSizing: 'border-box',
+ });
+
+ const label = document.createElement('div');
+ label.className = 'impeccable-label';
+ label.textContent = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
+ Object.assign(label.style, {
+ position: 'absolute', top: '-20px', left: '0',
+ background: LABEL_BG, color: 'white',
+ fontSize: '11px', fontFamily: 'system-ui, sans-serif', fontWeight: '600',
+ padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', letterSpacing: '0.02em',
+ });
+ outline.appendChild(label);
+
+ const tooltip = document.createElement('div');
+ tooltip.className = 'impeccable-tooltip';
+ tooltip.innerHTML = findings.map(f => f.detail || f.snippet).join('
');
+ Object.assign(tooltip.style, {
+ position: 'absolute', bottom: '-28px', left: '0',
+ background: 'rgba(0,0,0,0.85)', color: '#e5e5e5',
+ fontSize: '11px', fontFamily: 'ui-monospace, monospace',
+ padding: '4px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
+ lineHeight: '16px', display: 'none', zIndex: '100000',
+ });
+ outline.appendChild(tooltip);
+
+ outline.addEventListener('mouseenter', () => {
+ outline.style.pointerEvents = 'auto';
+ tooltip.style.display = 'block';
+ outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
+ });
+ outline.addEventListener('mouseleave', () => {
+ outline.style.pointerEvents = 'none';
+ tooltip.style.display = 'none';
+ outline.style.background = 'none';
+ });
+
+ document.body.appendChild(outline);
+ overlays.push(outline);
+ };
+
+ const showPageBanner = function(findings) {
+ if (!findings.length) return;
+ const banner = document.createElement('div');
+ banner.className = 'impeccable-overlay';
+ Object.assign(banner.style, {
+ position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
+ background: LABEL_BG, color: 'white',
+ fontFamily: 'system-ui, sans-serif', fontSize: '13px',
+ padding: '8px 16px', display: 'flex', flexWrap: 'wrap',
+ gap: '12px', alignItems: 'center', pointerEvents: 'auto',
+ });
+ for (const f of findings) {
+ const tag = document.createElement('span');
+ tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
+ Object.assign(tag.style, {
+ background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
+ borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
+ });
+ banner.appendChild(tag);
+ }
+ const close = document.createElement('button');
+ close.textContent = '\u00d7';
+ Object.assign(close.style, {
+ marginLeft: 'auto', background: 'none', border: 'none',
+ color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
+ });
+ close.addEventListener('click', () => banner.remove());
+ banner.appendChild(close);
+ document.body.appendChild(banner);
+ overlays.push(banner);
+ };
+
+ const printSummary = function(allFindings) {
+ if (allFindings.length === 0) {
+ console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
+ return;
+ }
+ console.group(
+ `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
+ 'color: oklch(60% 0.25 350); font-weight: bold'
+ );
+ for (const { el, findings } of allFindings) {
+ for (const f of findings) {
+ console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
+ 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
+ }
+ }
+ console.groupEnd();
+ };
+
+ const scan = function() {
+ for (const o of overlays) o.remove();
+ overlays.length = 0;
+ const allFindings = [];
+
+ for (const el of document.querySelectorAll('*')) {
+ if (el.classList.contains('impeccable-overlay') ||
+ el.classList.contains('impeccable-label') ||
+ el.classList.contains('impeccable-tooltip')) continue;
+
+ const findings = [
+ ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
+ ];
+
+ if (findings.length > 0) {
+ highlight(el, findings);
+ allFindings.push({ el, findings });
+ }
+ }
+
+ const typoFindings = checkTypography();
+ if (typoFindings.length > 0) {
+ showPageBanner(typoFindings);
+ allFindings.push({ el: document.body, findings: typoFindings });
+ }
+
+ const layoutFindings = checkLayout();
+ for (const f of layoutFindings) {
+ const el = f.el || document.body;
+ delete f.el;
+ highlight(el, [f]);
+ allFindings.push({ el, findings: [f] });
+ }
+
+ printSummary(allFindings);
+ return allFindings;
+ };
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
+ } else {
+ setTimeout(scan, 100);
+ }
+
+ window.impeccableScan = scan;
+}
+
+// ─── Section 8: Node Engine ─────────────────────────────────────────────────
+// @browser-strip-start
+
+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 };
+}
+
+/** Check if content looks like a full page (not a component/partial) */
+function isFullPage(content) {
+ const stripped = content.replace(//g, '');
+ return /]|]/i.test(stripped);
+}
+
// ---------------------------------------------------------------------------
// jsdom detection (default for HTML files)
// ---------------------------------------------------------------------------
@@ -402,7 +932,6 @@ async function detectHtml(filePath) {
try {
({ JSDOM } = await import('jsdom'));
} catch {
- // jsdom not available — fall back to regex
const content = fs.readFileSync(filePath, 'utf-8');
return detectText(content, filePath);
}
@@ -480,7 +1009,6 @@ async function detectUrl(url) {
}
// Read the browser detection script — reuse it instead of reimplementing
- // The generated browser script lives alongside this file after build
const browserScriptPath = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'detect-antipatterns-browser.js'
@@ -502,7 +1030,6 @@ async function detectUrl(url) {
const results = await page.evaluate(() => {
if (!window.impeccableScan) return [];
const allFindings = window.impeccableScan();
- // Flatten: each entry has { el, findings: [{type, detail}] }
return allFindings.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
);
@@ -516,7 +1043,6 @@ async function detectUrl(url) {
// 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);
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);
@@ -678,7 +1204,6 @@ const REGEX_ANALYZERS = [
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
- // Check lines that have text content elements
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
@@ -689,9 +1214,6 @@ const REGEX_ANALYZERS = [
},
];
-/**
- * Regex-based detection for non-HTML files or --fast mode.
- */
function detectText(content, filePath) {
const findings = [];
const lines = content.split('\n');
@@ -703,7 +1225,7 @@ function detectText(content, filePath) {
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));
+ findings.push(finding(matcher.id, filePath, matcher.fmt(m, line), i + 1));
}
}
}
@@ -876,11 +1398,18 @@ async function main() {
}
// ---------------------------------------------------------------------------
-// Entry point + exports
+// Entry point
// ---------------------------------------------------------------------------
-const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs');
-if (isMainModule) main();
+if (!IS_BROWSER) {
+ const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs');
+ if (isMainModule) main();
+}
+
+// @browser-strip-end
+
+// ─── Section 9: Exports ─────────────────────────────────────────────────────
+// @browser-strip-start
export {
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
@@ -888,3 +1417,5 @@ export {
detectHtml, detectUrl, detectText,
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
};
+
+// @browser-strip-end