diff --git a/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js b/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
new file mode 100644
index 000000000..c0dd172f9
--- /dev/null
+++ b/.claude/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
@@ -0,0 +1,335 @@
+/**
+ * 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-core.mjs b/.claude/skills/critique/scripts/detect-antipatterns-core.mjs
new file mode 100644
index 000000000..ef1982182
--- /dev/null
+++ b/.claude/skills/critique/scripts/detect-antipatterns-core.mjs
@@ -0,0 +1,297 @@
+/**
+ * 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 c26b89b48..202b5ec37 100644
--- a/.claude/skills/critique/scripts/detect-antipatterns.mjs
+++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs
@@ -18,120 +18,14 @@
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';
-// ---------------------------------------------------------------------------
-// Shared constants
-// ---------------------------------------------------------------------------
-
-const SAFE_TAGS = new Set([
- 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
- 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
- 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
- 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
- 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
-]);
-
-const OVERUSED_FONTS = new Set([
- 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
-]);
-
-const GENERIC_FONTS = new Set([
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
- 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
- '-apple-system', 'blinkmacsystemfont', 'segoe ui',
- 'inherit', 'initial', 'unset', 'revert',
-]);
-
-// ---------------------------------------------------------------------------
-// Anti-pattern definitions
-// ---------------------------------------------------------------------------
-
-const ANTIPATTERNS = [
- {
- id: 'side-tab',
- name: 'Side-tab accent border',
- description:
- 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
- },
- {
- id: 'border-accent-on-rounded',
- name: 'Border accent on rounded element',
- description:
- 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
- },
- {
- id: 'overused-font',
- name: 'Overused font',
- description:
- 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
- },
- {
- id: 'single-font',
- name: 'Single font for everything',
- description:
- 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
- },
- {
- id: 'flat-type-hierarchy',
- name: 'Flat type hierarchy',
- description:
- 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
- },
- // -------------------------------------------------------------------------
- // Color & contrast anti-patterns
- // -------------------------------------------------------------------------
- {
- 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.',
- },
- // -------------------------------------------------------------------------
- // Layout & space anti-patterns
- // -------------------------------------------------------------------------
- {
- 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.',
- },
-];
+// ANTIPATTERNS, constants, and color utilities imported from core
/** Check if content looks like a full page (not a component/partial) */
function isFullPage(content) {
@@ -149,84 +43,7 @@ function finding(id, filePath, snippet, line = 0) {
return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
}
-// ---------------------------------------------------------------------------
-// Computed-style detection (shared by jsdom + Puppeteer + browser)
-// ---------------------------------------------------------------------------
-
-/**
- * Check if an RGB color string is neutral (gray/structural).
- */
-function isNeutralColor(color) {
- if (!color || color === 'transparent') return true;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (!m) return true;
- const [r, g, b] = [+m[1], +m[2], +m[3]];
- return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
-}
-
-/**
- * Parse an RGB/RGBA color string into { r, g, b, a } (0-255 for rgb, 0-1 for a).
- * Returns null if unparseable.
- */
-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 };
-}
-
-/**
- * Compute relative luminance (WCAG 2.x formula).
- * Input: { r, g, b } with values 0-255.
- */
-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;
-}
-
-/**
- * Compute WCAG contrast ratio between two colors.
- * Returns a number >= 1.
- */
-function contrastRatio(c1, c2) {
- const l1 = relativeLuminance(c1);
- const l2 = relativeLuminance(c2);
- const lighter = Math.max(l1, l2);
- const darker = Math.min(l1, l2);
- return (lighter + 0.05) / (darker + 0.05);
-}
-
-/**
- * Check if a color has meaningful chroma (is "colored" vs gray/neutral).
- * Uses simple RGB saturation check.
- */
-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;
-}
-
-/**
- * Get the approximate hue (0-360) from RGB.
- */
-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('');
-}
+// Color utilities imported from core
/**
* Resolve the effective background color for an element by walking up ancestors.
@@ -266,158 +83,38 @@ function resolveBackground(el, window) {
}
/**
- * Analyze an element's colors for anti-patterns.
- * Needs the element, its computed style, AND access to the window for ancestor bg resolution.
+ * Extract color data from element/style and delegate to core.checkColors.
+ * Keeps jsdom-specific resolveBackground here.
*/
function checkElementColors(el, style, tag, window) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
- const textColor = parseRgb(style.color);
- const bgColor = parseRgb(style.backgroundColor);
- const fontSize = parseFloat(style.fontSize) || 16;
- const fontWeight = parseInt(style.fontWeight) || 400;
-
- // Skip non-text elements (no text content)
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
- // Pure black background — only flag #000 as background (not text, not white)
- 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 text on colored background ---
- const effectiveBg = resolveBackground(el, window);
- // Gray = low chroma AND mid-range luminance (not near-white or near-black)
- 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);
- // jsdom may return fontSize in non-px units — also check tag-based heuristic
- 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)}`,
- });
- }
- }
- }
-
- // --- Gradient text ---
- const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
- const bgImage = style.backgroundImage || '';
- if (bgClip === 'text' && bgImage.includes('gradient')) {
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- }
-
- // --- AI color palette: purple/violet accent ---
- // Only flag vivid purple/violet as text color or background on accent-like elements
- if (hasDirectText && textColor && hasChroma(textColor, 50)) {
- const hue = getHue(textColor);
- // Purple/violet range: roughly 260-310
- if (hue >= 260 && hue <= 310 && relativeLuminance(textColor) < 0.3) {
- // Check if it's used on a heading or prominent text
- if (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20) {
- findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
- }
- }
- }
-
- // --- Tailwind class-based color checks ---
- const classList = el.getAttribute?.('class') || el.className || '';
- if (classList) {
- const TW_GRAY_FAMILIES = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
- const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
-
- // Only flag bg-black (pure black background) — text colors are fine
- if (/\bbg-black\b/.test(classList)) {
- findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
- }
-
- // Tailwind gray text on colored background
- const grayMatch = classList.match(TW_GRAY_FAMILIES);
- const coloredBgMatch = classList.match(TW_COLORED_BG);
- if (grayMatch && coloredBgMatch) {
- findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${coloredBgMatch[0]}` });
- }
-
- // Tailwind gradient text
- if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
- findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient-to (Tailwind)' });
- }
-
- // Tailwind AI palette: purple/violet text on headings
- const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
- if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl|[3-9]xl)\b/.test(classList))) {
- findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
- }
-
- // Tailwind AI palette: purple/violet gradient
- 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;
+ 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 || '',
+ });
}
/**
- * Analyze a single element's computed styles for border anti-patterns.
- * Returns array of { id, snippet } findings.
+ * Extract border data from computed style and delegate to core.checkBorders.
*/
function checkElementBorders(tag, style) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {};
- const colors = {};
+ const widths = {}, colors = {};
for (const s of sides) {
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
colors[s] = style[`border${s}Color`] || '';
}
- const radius = parseFloat(style.borderRadius) || 0;
-
- for (const side of sides) {
- const w = widths[side];
- if (w < 1) continue;
- if (isNeutralColor(colors[side])) continue;
-
- const otherSides = sides.filter(s => s !== side);
- const maxOther = Math.max(...otherSides.map(s => widths[s]));
- const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
- if (!isAccent) continue;
-
- const sideName = side.toLowerCase();
- const isSide = side === 'Left' || side === 'Right';
-
- if (isSide) {
- if (radius > 0) {
- findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
- } else if (w >= 3) {
- findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px` });
- }
- } else {
- if (radius > 0 && w >= 2) {
- findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
- }
- }
- }
-
- return findings;
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
}
/**
@@ -539,47 +236,27 @@ function checkPageTypography(document, window) {
}
/**
- * Check if an element looks like a "card" (has shadow, border-radius, and background).
+ * 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) {
- const style = window.getComputedStyle(el);
const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
- // Skip non-visual elements
- if (SAFE_TAGS.has(tag)) return false;
- // Skip form elements (inputs, selects, textareas have shadow/rounded)
- if (['input', 'select', 'textarea'].includes(tag)) return false;
- // Skip images, media
- if (['img', 'video', 'canvas', 'picture'].includes(tag)) return false;
-
- const shadow = style.boxShadow || '';
- const hasShadow = shadow && shadow !== 'none';
- const radius = parseFloat(style.borderRadius) || 0;
- const hasRadius = radius > 0;
-
- // Also check raw inline style (jsdom doesn't resolve shorthand properties reliably)
+ const style = window.getComputedStyle(el);
const rawStyle = el.getAttribute?.('style') || '';
- const rawShadow = /box-shadow/i.test(rawStyle);
- const rawRadius = /border-radius/i.test(rawStyle);
- const rawBg = /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
-
- // Also check Tailwind classes for card indicators
const cls = el.getAttribute?.('class') || '';
- const twShadow = /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
- const twRounded = /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
- const twBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
- const twBorder = /\bborder\b/.test(cls);
- // A "card" needs shadow (or border) AND at least one of: rounded, bg
- const hasShadowAny = hasShadow || twShadow || rawShadow;
- const hasBorderAny = twBorder;
- const hasRadiusAny = hasRadius || twRounded || rawRadius;
- const hasBgAny = rawBg || twBg;
+ 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);
- // Must have shadow or border (the key card indicator)
- if (!hasShadowAny && !hasBorderAny) return false;
- // Plus at least one of: rounded, background
- return hasRadiusAny || hasBgAny;
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
}
/**
diff --git a/public/js/detect-antipatterns-browser.js b/public/js/detect-antipatterns-browser.js
index 276d4d2c9..babd1393f 100644
--- a/public/js/detect-antipatterns-browser.js
+++ b/public/js/detect-antipatterns-browser.js
@@ -1,8 +1,7 @@
/**
* Anti-Pattern Browser Detector for Impeccable
- *
- * Drop this script into any page to visually highlight UI anti-patterns.
- * Uses getComputedStyle() and document.styleSheets for accurate detection.
+ * GENERATED — do not edit. Source: detect-antipatterns-core.mjs + this wrapper.
+ * Rebuild: node scripts/build-browser-detector.js
*
* Usage:
* Re-scan: window.impeccableScan()
@@ -13,111 +12,303 @@
const LABEL_BG = 'oklch(55% 0.25 350)';
const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
- 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',
- ]);
+ // ===========================================================================
+ // Core detection logic (injected from detect-antipatterns-core.mjs at build)
+ // ===========================================================================
- const OVERUSED_FONTS = new Set([
- 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
- ]);
+ // ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
- 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 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',
+]);
- // -----------------------------------------------------------------------
- // Detection (computed styles)
- // -----------------------------------------------------------------------
+const OVERUSED_FONTS = new Set([
+ 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
+]);
- 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;
+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.',
+ },
+];
+
+// ---------------------------------------------------------------------------
+// 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('');
+}
+
+// ---------------------------------------------------------------------------
+// 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 }>}
+ */
+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` });
+ }
}
- function checkElementBorders(el) {
- const tag = el.tagName.toLowerCase();
- if (SAFE_TAGS.has(tag)) return [];
- const rect = el.getBoundingClientRect();
- if (rect.width < 20 || rect.height < 20) return [];
+ return findings;
+}
- const findings = [];
- 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`] || '';
+/**
+ * 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 }>}
+ */
+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)}` });
}
- const radius = parseFloat(style.borderRadius) || 0;
- for (const side of sides) {
- const w = widths[side];
- if (w < 1 || isNeutralColor(colors[side])) continue;
+ // 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)}` });
+ }
- const others = sides.filter(s => s !== side);
- const maxOther = Math.max(...others.map(s => widths[s]));
- if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
-
- const sn = side.toLowerCase();
- const isSide = side === 'Left' || side === 'Right';
-
- if (isSide) {
- if (radius > 0) findings.push({ type: 'side-tab', detail: `border-${sn}: ${w}px + border-radius: ${radius}px` });
- else if (w >= 3) findings.push({ type: 'side-tab', detail: `border-${sn}: ${w}px` });
- } else {
- if (radius > 0 && w >= 2) findings.push({ type: 'border-accent-on-rounded', detail: `border-${sn}: ${w}px + border-radius: ${radius}px` });
+ // 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` });
}
}
- return findings;
}
- 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 };
+ // Gradient text
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
}
- 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;
+ // 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)' });
+ }
}
- function contrastRatio(c1, c2) {
- const l1 = relativeLuminance(c1), l2 = relativeLuminance(c2);
- return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
- }
+ return findings;
+}
- function colorToHex(c) {
- return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
- }
+/**
+ * Check if an element's properties make it "card-like".
+ */
+function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
+ if (!hasShadow && !hasBorder) return false;
+ return hasRadius || hasBg;
+}
- function hasChroma(c, threshold = 30) {
- return c && (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
- }
-
- function getHue(c) {
- 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);
- }
+ // ===========================================================================
+ // Browser-specific: DOM element adapters
+ // ===========================================================================
function resolveBackground(el) {
let current = el;
@@ -129,95 +320,51 @@
return { r: 255, g: 255, b: 255 };
}
- function checkElementColors(el) {
+ 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 findings = [];
const style = getComputedStyle(el);
- const textColor = parseRgb(style.color);
- const bgColor = parseRgb(style.backgroundColor);
- const fontSize = parseFloat(style.fontSize) || 16;
- const fontWeight = parseInt(style.fontWeight) || 400;
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
- const classList = el.getAttribute('class') || '';
-
- // --- Pure black background only (#000 bg — #fff and text colors are fine) ---
- if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
- findings.push({ type: 'pure-black-white', detail: '#000 background' });
- }
-
- if (hasDirectText && textColor) {
- const effectiveBg = resolveBackground(el);
-
- // --- 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({ type: 'gray-on-color', detail: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
- }
-
- // --- Low contrast ---
- const ratio = contrastRatio(textColor, effectiveBg);
- const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || ['h1','h2','h3'].includes(tag);
- const threshold = isLargeText ? 3.0 : 4.5;
- if (ratio < threshold) {
- findings.push({ type: 'low-contrast', detail: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — ${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({ type: 'ai-color-palette', detail: `Purple text (${colorToHex(textColor)}) on heading` });
- }
- }
- }
-
- // --- Gradient text ---
- const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
- if (bgClip === 'text' && (style.backgroundImage || '').includes('gradient')) {
- findings.push({ type: 'gradient-text', detail: 'background-clip: text + gradient' });
- }
-
- // --- Tailwind class checks ---
- if (classList) {
- const TW_GRAY_TEXT = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
- const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
-
- if (/\bbg-black\b/.test(classList)) findings.push({ type: 'pure-black-white', detail: 'bg-black' });
-
- const grayMatch = classList.match(TW_GRAY_TEXT);
- const colorBgMatch = classList.match(TW_COLORED_BG);
- if (grayMatch && colorBgMatch) {
- findings.push({ type: 'gray-on-color', detail: `${grayMatch[0]} on ${colorBgMatch[0]}` });
- }
-
- if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
- findings.push({ type: 'gradient-text', detail: '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({ type: 'ai-color-palette', detail: `${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({ type: 'ai-color-palette', detail: 'Purple gradient (Tailwind)' });
- }
- }
-
- return findings;
+ 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 = [];
-
- // Collect fonts from stylesheets
const fonts = new Set();
const overusedFound = new Set();
+
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
@@ -235,7 +382,6 @@
}
}
- // Google Fonts links
const html = document.documentElement.outerHTML;
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
@@ -249,12 +395,10 @@
for (const font of overusedFound) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
}
-
- if (fonts.size === 1 && document.querySelectorAll('*').length > 20) {
+ if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ type: 'single-font', detail: `Only font: ${[...fonts][0]}` });
}
- // Flat type hierarchy
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);
@@ -271,31 +415,24 @@
return findings;
}
- // -----------------------------------------------------------------------
- // Layout checks (page-level)
- // -----------------------------------------------------------------------
-
- function isCardLike(el) {
+ 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);
- const hasBorder = /\bborder\b/.test(cls);
- // Must have shadow or border (the key card indicator), plus rounded or bg
- if (!hasShadow && !hasBorder) return false;
- return hasRadius || hasBg;
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
}
function checkLayout() {
const findings = [];
-
- // --- Nested cards ---
const flaggedEls = new Set();
+
for (const el of document.querySelectorAll('*')) {
- if (!isCardLike(el) || flaggedEls.has(el)) continue;
+ if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
const cls = el.getAttribute('class') || '';
const style = getComputedStyle(el);
if (style.position === 'absolute' || style.position === 'fixed') continue;
@@ -306,46 +443,31 @@
let parent = el.parentElement;
while (parent) {
- if (isCardLike(parent)) {
- flaggedEls.add(el);
- break;
- }
+ if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
parent = parent.parentElement;
}
}
- // Only report innermost nested cards — skip any that are ancestors of other flagged cards
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 });
- }
+ if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
}
return findings;
}
- // -----------------------------------------------------------------------
- // Highlighting
- // -----------------------------------------------------------------------
+ // ===========================================================================
+ // Highlighting & UI
+ // ===========================================================================
const overlays = [];
- const TYPE_LABELS = {
- 'side-tab': 'side-tab',
- 'border-accent-on-rounded': 'accent+rounded',
- 'overused-font': 'overused font',
- 'single-font': 'single font',
- 'flat-type-hierarchy': 'flat hierarchy',
- 'pure-black-white': 'pure #000/#fff',
- 'gray-on-color': 'gray on color',
- 'low-contrast': 'low contrast',
- 'gradient-text': 'gradient text',
- 'ai-color-palette': 'AI palette',
- 'nested-cards': 'nested cards',
- };
+ 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();
@@ -353,20 +475,15 @@
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',
+ 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.type).join(', ');
+ 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',
@@ -378,7 +495,7 @@
const tooltip = document.createElement('div');
tooltip.className = 'impeccable-tooltip';
- tooltip.innerHTML = findings.map(f => f.detail).join('
');
+ 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',
@@ -435,10 +552,6 @@
overlays.push(banner);
}
- // -----------------------------------------------------------------------
- // Console summary
- // -----------------------------------------------------------------------
-
function printSummary(allFindings) {
if (allFindings.length === 0) {
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
@@ -450,42 +563,44 @@
);
for (const { el, findings } of allFindings) {
for (const f of findings) {
- console.log(`%c${f.type}%c ${f.detail}`, 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
+ 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 = [];
- // Element-level border checks
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 = [...checkElementBorders(el), ...checkElementColors(el)];
+
+ 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 });
}
}
- // Page-level typography checks
const typoFindings = checkTypography();
if (typoFindings.length > 0) {
showPageBanner(typoFindings);
allFindings.push({ el: document.body, findings: typoFindings });
}
- // Page-level layout checks
const layoutFindings = checkLayout();
for (const f of layoutFindings) {
const el = f.el || document.body;
diff --git a/scripts/build-browser-detector.js b/scripts/build-browser-detector.js
new file mode 100644
index 000000000..8ecaef2c6
--- /dev/null
+++ b/scripts/build-browser-detector.js
@@ -0,0 +1,37 @@
+#!/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
+ *
+ * Run: node scripts/build-browser-detector.js
+ */
+
+import fs from 'fs';
+import path from 'path';
+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, 'public/js/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();
+
+// Read the browser wrapper
+const wrapper = fs.readFileSync(WRAPPER_PATH, 'utf-8');
+
+// Inject core into the wrapper at the marker
+const output = wrapper.replace('// {{CORE_INJECTION_POINT}}', core);
+
+fs.writeFileSync(OUTPUT_PATH, output);
+console.log(`✓ Generated ${path.relative(ROOT, OUTPUT_PATH)} (${(output.length / 1024).toFixed(1)} KB)`);
diff --git a/scripts/build.js b/scripts/build.js
index 743657fa6..be4f29866 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -322,6 +322,13 @@ async function build() {
// Build CSS with Tailwind CLI (handles @theme directive)
buildTailwindCSS();
+ // Generate browser anti-pattern detector from core module
+ try {
+ execSync('node scripts/build-browser-detector.js', { cwd: ROOT_DIR, stdio: 'inherit' });
+ } catch (error) {
+ console.error('Failed to build browser detector:', error.message);
+ }
+
// Bundle HTML, JS, and compiled CSS with Bun
await buildStaticSite();
diff --git a/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js b/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
new file mode 100644
index 000000000..c0dd172f9
--- /dev/null
+++ b/source/skills/critique/scripts/detect-antipatterns-browser-wrapper.js
@@ -0,0 +1,335 @@
+/**
+ * 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
new file mode 100644
index 000000000..ef1982182
--- /dev/null
+++ b/source/skills/critique/scripts/detect-antipatterns-core.mjs
@@ -0,0 +1,297 @@
+/**
+ * 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 c26b89b48..202b5ec37 100644
--- a/source/skills/critique/scripts/detect-antipatterns.mjs
+++ b/source/skills/critique/scripts/detect-antipatterns.mjs
@@ -18,120 +18,14 @@
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';
-// ---------------------------------------------------------------------------
-// Shared constants
-// ---------------------------------------------------------------------------
-
-const SAFE_TAGS = new Set([
- 'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
- 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
- 'button', 'hr', 'html', 'head', 'body', 'script', 'style',
- 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
- 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
-]);
-
-const OVERUSED_FONTS = new Set([
- 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
-]);
-
-const GENERIC_FONTS = new Set([
- 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
- 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
- '-apple-system', 'blinkmacsystemfont', 'segoe ui',
- 'inherit', 'initial', 'unset', 'revert',
-]);
-
-// ---------------------------------------------------------------------------
-// Anti-pattern definitions
-// ---------------------------------------------------------------------------
-
-const ANTIPATTERNS = [
- {
- id: 'side-tab',
- name: 'Side-tab accent border',
- description:
- 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
- },
- {
- id: 'border-accent-on-rounded',
- name: 'Border accent on rounded element',
- description:
- 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
- },
- {
- id: 'overused-font',
- name: 'Overused font',
- description:
- 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
- },
- {
- id: 'single-font',
- name: 'Single font for everything',
- description:
- 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
- },
- {
- id: 'flat-type-hierarchy',
- name: 'Flat type hierarchy',
- description:
- 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
- },
- // -------------------------------------------------------------------------
- // Color & contrast anti-patterns
- // -------------------------------------------------------------------------
- {
- 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.',
- },
- // -------------------------------------------------------------------------
- // Layout & space anti-patterns
- // -------------------------------------------------------------------------
- {
- 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.',
- },
-];
+// ANTIPATTERNS, constants, and color utilities imported from core
/** Check if content looks like a full page (not a component/partial) */
function isFullPage(content) {
@@ -149,84 +43,7 @@ function finding(id, filePath, snippet, line = 0) {
return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
}
-// ---------------------------------------------------------------------------
-// Computed-style detection (shared by jsdom + Puppeteer + browser)
-// ---------------------------------------------------------------------------
-
-/**
- * Check if an RGB color string is neutral (gray/structural).
- */
-function isNeutralColor(color) {
- if (!color || color === 'transparent') return true;
- const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (!m) return true;
- const [r, g, b] = [+m[1], +m[2], +m[3]];
- return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
-}
-
-/**
- * Parse an RGB/RGBA color string into { r, g, b, a } (0-255 for rgb, 0-1 for a).
- * Returns null if unparseable.
- */
-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 };
-}
-
-/**
- * Compute relative luminance (WCAG 2.x formula).
- * Input: { r, g, b } with values 0-255.
- */
-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;
-}
-
-/**
- * Compute WCAG contrast ratio between two colors.
- * Returns a number >= 1.
- */
-function contrastRatio(c1, c2) {
- const l1 = relativeLuminance(c1);
- const l2 = relativeLuminance(c2);
- const lighter = Math.max(l1, l2);
- const darker = Math.min(l1, l2);
- return (lighter + 0.05) / (darker + 0.05);
-}
-
-/**
- * Check if a color has meaningful chroma (is "colored" vs gray/neutral).
- * Uses simple RGB saturation check.
- */
-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;
-}
-
-/**
- * Get the approximate hue (0-360) from RGB.
- */
-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('');
-}
+// Color utilities imported from core
/**
* Resolve the effective background color for an element by walking up ancestors.
@@ -266,158 +83,38 @@ function resolveBackground(el, window) {
}
/**
- * Analyze an element's colors for anti-patterns.
- * Needs the element, its computed style, AND access to the window for ancestor bg resolution.
+ * Extract color data from element/style and delegate to core.checkColors.
+ * Keeps jsdom-specific resolveBackground here.
*/
function checkElementColors(el, style, tag, window) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
- const textColor = parseRgb(style.color);
- const bgColor = parseRgb(style.backgroundColor);
- const fontSize = parseFloat(style.fontSize) || 16;
- const fontWeight = parseInt(style.fontWeight) || 400;
-
- // Skip non-text elements (no text content)
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
- // Pure black background — only flag #000 as background (not text, not white)
- 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 text on colored background ---
- const effectiveBg = resolveBackground(el, window);
- // Gray = low chroma AND mid-range luminance (not near-white or near-black)
- 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);
- // jsdom may return fontSize in non-px units — also check tag-based heuristic
- 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)}`,
- });
- }
- }
- }
-
- // --- Gradient text ---
- const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
- const bgImage = style.backgroundImage || '';
- if (bgClip === 'text' && bgImage.includes('gradient')) {
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
- }
-
- // --- AI color palette: purple/violet accent ---
- // Only flag vivid purple/violet as text color or background on accent-like elements
- if (hasDirectText && textColor && hasChroma(textColor, 50)) {
- const hue = getHue(textColor);
- // Purple/violet range: roughly 260-310
- if (hue >= 260 && hue <= 310 && relativeLuminance(textColor) < 0.3) {
- // Check if it's used on a heading or prominent text
- if (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20) {
- findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
- }
- }
- }
-
- // --- Tailwind class-based color checks ---
- const classList = el.getAttribute?.('class') || el.className || '';
- if (classList) {
- const TW_GRAY_FAMILIES = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
- const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
-
- // Only flag bg-black (pure black background) — text colors are fine
- if (/\bbg-black\b/.test(classList)) {
- findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
- }
-
- // Tailwind gray text on colored background
- const grayMatch = classList.match(TW_GRAY_FAMILIES);
- const coloredBgMatch = classList.match(TW_COLORED_BG);
- if (grayMatch && coloredBgMatch) {
- findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${coloredBgMatch[0]}` });
- }
-
- // Tailwind gradient text
- if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
- findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient-to (Tailwind)' });
- }
-
- // Tailwind AI palette: purple/violet text on headings
- const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
- if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl|[3-9]xl)\b/.test(classList))) {
- findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
- }
-
- // Tailwind AI palette: purple/violet gradient
- 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;
+ 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 || '',
+ });
}
/**
- * Analyze a single element's computed styles for border anti-patterns.
- * Returns array of { id, snippet } findings.
+ * Extract border data from computed style and delegate to core.checkBorders.
*/
function checkElementBorders(tag, style) {
- if (SAFE_TAGS.has(tag)) return [];
- const findings = [];
-
const sides = ['Top', 'Right', 'Bottom', 'Left'];
- const widths = {};
- const colors = {};
+ const widths = {}, colors = {};
for (const s of sides) {
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
colors[s] = style[`border${s}Color`] || '';
}
- const radius = parseFloat(style.borderRadius) || 0;
-
- for (const side of sides) {
- const w = widths[side];
- if (w < 1) continue;
- if (isNeutralColor(colors[side])) continue;
-
- const otherSides = sides.filter(s => s !== side);
- const maxOther = Math.max(...otherSides.map(s => widths[s]));
- const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
- if (!isAccent) continue;
-
- const sideName = side.toLowerCase();
- const isSide = side === 'Left' || side === 'Right';
-
- if (isSide) {
- if (radius > 0) {
- findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
- } else if (w >= 3) {
- findings.push({ id: 'side-tab', snippet: `border-${sideName}: ${w}px` });
- }
- } else {
- if (radius > 0 && w >= 2) {
- findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sideName}: ${w}px + border-radius: ${radius}px` });
- }
- }
- }
-
- return findings;
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
}
/**
@@ -539,47 +236,27 @@ function checkPageTypography(document, window) {
}
/**
- * Check if an element looks like a "card" (has shadow, border-radius, and background).
+ * 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) {
- const style = window.getComputedStyle(el);
const tag = el.tagName.toLowerCase();
+ if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
- // Skip non-visual elements
- if (SAFE_TAGS.has(tag)) return false;
- // Skip form elements (inputs, selects, textareas have shadow/rounded)
- if (['input', 'select', 'textarea'].includes(tag)) return false;
- // Skip images, media
- if (['img', 'video', 'canvas', 'picture'].includes(tag)) return false;
-
- const shadow = style.boxShadow || '';
- const hasShadow = shadow && shadow !== 'none';
- const radius = parseFloat(style.borderRadius) || 0;
- const hasRadius = radius > 0;
-
- // Also check raw inline style (jsdom doesn't resolve shorthand properties reliably)
+ const style = window.getComputedStyle(el);
const rawStyle = el.getAttribute?.('style') || '';
- const rawShadow = /box-shadow/i.test(rawStyle);
- const rawRadius = /border-radius/i.test(rawStyle);
- const rawBg = /background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
-
- // Also check Tailwind classes for card indicators
const cls = el.getAttribute?.('class') || '';
- const twShadow = /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
- const twRounded = /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
- const twBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
- const twBorder = /\bborder\b/.test(cls);
- // A "card" needs shadow (or border) AND at least one of: rounded, bg
- const hasShadowAny = hasShadow || twShadow || rawShadow;
- const hasBorderAny = twBorder;
- const hasRadiusAny = hasRadius || twRounded || rawRadius;
- const hasBgAny = rawBg || twBg;
+ 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);
- // Must have shadow or border (the key card indicator)
- if (!hasShadowAny && !hasBorderAny) return false;
- // Plus at least one of: rounded, background
- return hasRadiusAny || hasBgAny;
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
}
/**