From fbfe525f4f50ffeb9e871243112364afb359e4a3 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 17 Mar 2026 18:11:35 -0700 Subject: [PATCH] Move generated browser script to .claude/skills/critique/scripts/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated browser detector now lives alongside the CLI script in .claude/skills/critique/scripts/ — clearly a build artifact, not a hand-maintained source file in public/js/. - build-browser-detector.js outputs to .claude/ instead of public/js/ - Dev server serves .claude/skills/* for local testing - All fixture and antipattern-example HTML files updated to new path - Puppeteer detectUrl reads browser script from same directory - Browser parity test server updated to serve from .claude/ - Deleted public/js/detect-antipatterns-browser.js Co-Authored-By: Claude Opus 4.6 (1M context) --- .../critique/scripts/detect-antipatterns.mjs | 3 +- public/antipattern-examples/bad-contrast.html | 2 +- public/antipattern-examples/cardocalypse.html | 2 +- .../inter-everywhere.html | 2 +- .../layout-templates.html | 2 +- public/antipattern-examples/lazy-cool.html | 2 +- public/antipattern-examples/lazy-impact.html | 2 +- .../antipattern-examples/massive-icons.html | 2 +- public/antipattern-examples/modal-abuse.html | 2 +- .../purple-gradients.html | 2 +- .../redundant-ux-writing.html | 2 +- .../thick-border-cards.html | 2 +- public/js/detect-antipatterns-browser.js | 623 ------------------ scripts/build-browser-detector.js | 2 +- server/index.js | 13 + .../critique/scripts/detect-antipatterns.mjs | 3 +- tests/detect-antipatterns-browser.test.js | 4 +- .../antipatterns/color-should-flag.html | 2 +- .../antipatterns/color-should-pass.html | 2 +- .../antipatterns/layout-should-flag.html | 2 +- .../antipatterns/layout-should-pass.html | 2 +- .../antipatterns/legitimate-borders.html | 2 +- .../antipatterns/linked-stylesheet.html | 2 +- .../antipatterns/partial-component.html | 2 +- tests/fixtures/antipatterns/should-flag.html | 2 +- tests/fixtures/antipatterns/should-pass.html | 2 +- .../antipatterns/typography-should-flag.html | 2 +- .../antipatterns/typography-should-pass.html | 2 +- 28 files changed, 42 insertions(+), 650 deletions(-) delete mode 100644 public/js/detect-antipatterns-browser.js diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs index 202b5ec37..5e7d91c65 100644 --- a/.claude/skills/critique/scripts/detect-antipatterns.mjs +++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs @@ -480,9 +480,10 @@ async function detectUrl(url) { } // Read the browser detection script — reuse it instead of reimplementing + // The generated browser script lives alongside this file after build const browserScriptPath = path.resolve( path.dirname(new URL(import.meta.url).pathname), - '..', '..', '..', '..', 'public', 'js', 'detect-antipatterns-browser.js' + 'detect-antipatterns-browser.js' ); let browserScript; try { diff --git a/public/antipattern-examples/bad-contrast.html b/public/antipattern-examples/bad-contrast.html index c77c9d83e..65548336b 100644 --- a/public/antipattern-examples/bad-contrast.html +++ b/public/antipattern-examples/bad-contrast.html @@ -202,6 +202,6 @@ - + diff --git a/public/antipattern-examples/cardocalypse.html b/public/antipattern-examples/cardocalypse.html index 01e2a24c0..6264b23db 100644 --- a/public/antipattern-examples/cardocalypse.html +++ b/public/antipattern-examples/cardocalypse.html @@ -370,6 +370,6 @@ - + diff --git a/public/antipattern-examples/inter-everywhere.html b/public/antipattern-examples/inter-everywhere.html index 5fe854475..f657978ea 100644 --- a/public/antipattern-examples/inter-everywhere.html +++ b/public/antipattern-examples/inter-everywhere.html @@ -312,6 +312,6 @@ - + diff --git a/public/antipattern-examples/layout-templates.html b/public/antipattern-examples/layout-templates.html index 9dbda4b12..06067e8b0 100644 --- a/public/antipattern-examples/layout-templates.html +++ b/public/antipattern-examples/layout-templates.html @@ -317,6 +317,6 @@ - + diff --git a/public/antipattern-examples/lazy-cool.html b/public/antipattern-examples/lazy-cool.html index e80b0d4a1..745cc5ef0 100644 --- a/public/antipattern-examples/lazy-cool.html +++ b/public/antipattern-examples/lazy-cool.html @@ -315,6 +315,6 @@ - + diff --git a/public/antipattern-examples/lazy-impact.html b/public/antipattern-examples/lazy-impact.html index e6bbbc8af..c4a465f9f 100644 --- a/public/antipattern-examples/lazy-impact.html +++ b/public/antipattern-examples/lazy-impact.html @@ -373,6 +373,6 @@ - + diff --git a/public/antipattern-examples/massive-icons.html b/public/antipattern-examples/massive-icons.html index aa439e7ec..3c569db27 100644 --- a/public/antipattern-examples/massive-icons.html +++ b/public/antipattern-examples/massive-icons.html @@ -194,6 +194,6 @@ - + diff --git a/public/antipattern-examples/modal-abuse.html b/public/antipattern-examples/modal-abuse.html index 367a03801..ed207434a 100644 --- a/public/antipattern-examples/modal-abuse.html +++ b/public/antipattern-examples/modal-abuse.html @@ -431,6 +431,6 @@ - + diff --git a/public/antipattern-examples/purple-gradients.html b/public/antipattern-examples/purple-gradients.html index d5abc3c45..88bbe5d31 100644 --- a/public/antipattern-examples/purple-gradients.html +++ b/public/antipattern-examples/purple-gradients.html @@ -236,6 +236,6 @@ - + diff --git a/public/antipattern-examples/redundant-ux-writing.html b/public/antipattern-examples/redundant-ux-writing.html index fd426029e..81de38ae9 100644 --- a/public/antipattern-examples/redundant-ux-writing.html +++ b/public/antipattern-examples/redundant-ux-writing.html @@ -202,6 +202,6 @@ - + diff --git a/public/antipattern-examples/thick-border-cards.html b/public/antipattern-examples/thick-border-cards.html index 5c6740a49..9f9136d28 100644 --- a/public/antipattern-examples/thick-border-cards.html +++ b/public/antipattern-examples/thick-border-cards.html @@ -200,6 +200,6 @@ - + diff --git a/public/js/detect-antipatterns-browser.js b/public/js/detect-antipatterns-browser.js deleted file mode 100644 index babd1393f..000000000 --- a/public/js/detect-antipatterns-browser.js +++ /dev/null @@ -1,623 +0,0 @@ -/** - * Anti-Pattern Browser Detector for Impeccable - * GENERATED — do not edit. Source: detect-antipatterns-core.mjs + this wrapper. - * Rebuild: node scripts/build-browser-detector.js - * - * Usage: - * Re-scan: window.impeccableScan() - */ -(function () { - if (typeof window === 'undefined') return; - - const LABEL_BG = 'oklch(55% 0.25 350)'; - const OUTLINE_COLOR = 'oklch(60% 0.25 350)'; - - // =========================================================================== - // Core detection logic (injected from detect-antipatterns-core.mjs at build) - // =========================================================================== - - // --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const SAFE_TAGS = new Set([ - 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', - 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', - 'button', 'hr', 'html', 'head', 'body', 'script', 'style', - 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', - 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', -]); - -const OVERUSED_FONTS = new Set([ - 'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica', -]); - -const GENERIC_FONTS = new Set([ - 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', - 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', - '-apple-system', 'blinkmacsystemfont', 'segoe ui', - 'inherit', 'initial', 'unset', 'revert', -]); - -const ANTIPATTERNS = [ - { - id: 'side-tab', - name: 'Side-tab accent border', - description: - 'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.', - }, - { - id: 'border-accent-on-rounded', - name: 'Border accent on rounded element', - description: - 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', - }, - { - id: 'overused-font', - name: 'Overused font', - description: - 'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.', - }, - { - id: 'single-font', - name: 'Single font for everything', - description: - 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', - }, - { - id: 'flat-type-hierarchy', - name: 'Flat type hierarchy', - description: - 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', - }, - { - id: 'pure-black-white', - name: 'Pure black background', - description: - 'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.', - }, - { - id: 'gray-on-color', - name: 'Gray text on colored background', - description: - 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', - }, - { - id: 'low-contrast', - name: 'Low contrast text', - description: - 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', - }, - { - id: 'gradient-text', - name: 'Gradient text', - description: - 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', - }, - { - id: 'ai-color-palette', - name: 'AI color palette', - description: - 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', - }, - { - id: 'nested-cards', - name: 'Nested cards', - description: - 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', - }, - { - id: 'monotonous-spacing', - name: 'Monotonous spacing', - description: - 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', - }, - { - id: 'everything-centered', - name: 'Everything centered', - description: - 'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.', - }, -]; - -// --------------------------------------------------------------------------- -// 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` }); - } - } - - 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 }>} - */ -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". - */ -function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) { - if (!hasShadow && !hasBorder) return false; - return hasRadius || hasBg; -} - - // =========================================================================== - // 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/scripts/build-browser-detector.js b/scripts/build-browser-detector.js index 8ecaef2c6..e7d3d96e2 100644 --- a/scripts/build-browser-detector.js +++ b/scripts/build-browser-detector.js @@ -18,7 +18,7 @@ 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'); +const OUTPUT_PATH = path.join(ROOT, '.claude/skills/critique/scripts/detect-antipatterns-browser.js'); // Read and strip exports from core let core = fs.readFileSync(CORE_PATH, 'utf-8'); diff --git a/server/index.js b/server/index.js index b47615154..3c49bc071 100644 --- a/server/index.js +++ b/server/index.js @@ -19,6 +19,19 @@ const server = serve({ "/cheatsheet": cheatsheet, "/gallery": gallery, + // Built skill scripts (.claude/skills/) + "/.claude/skills/*": async (req) => { + const url = new URL(req.url); + if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 }); + const filePath = `.${url.pathname}`; + const assetFile = file(filePath); + if (await assetFile.exists()) { + return new Response(assetFile, { + headers: { "Content-Type": "application/javascript", "X-Content-Type-Options": "nosniff" } + }); + } + return new Response("Not Found", { status: 404 }); + }, // Static assets - all public subdirectories "/assets/*": async (req) => { const url = new URL(req.url); diff --git a/source/skills/critique/scripts/detect-antipatterns.mjs b/source/skills/critique/scripts/detect-antipatterns.mjs index 202b5ec37..5e7d91c65 100644 --- a/source/skills/critique/scripts/detect-antipatterns.mjs +++ b/source/skills/critique/scripts/detect-antipatterns.mjs @@ -480,9 +480,10 @@ async function detectUrl(url) { } // Read the browser detection script — reuse it instead of reimplementing + // The generated browser script lives alongside this file after build const browserScriptPath = path.resolve( path.dirname(new URL(import.meta.url).pathname), - '..', '..', '..', '..', 'public', 'js', 'detect-antipatterns-browser.js' + 'detect-antipatterns-browser.js' ); let browserScript; try { diff --git a/tests/detect-antipatterns-browser.test.js b/tests/detect-antipatterns-browser.test.js index a84b369a5..275c7d3a3 100644 --- a/tests/detect-antipatterns-browser.test.js +++ b/tests/detect-antipatterns-browser.test.js @@ -38,8 +38,8 @@ describeIf('browser script parity with CLI', () => { let filePath; if (req.url.startsWith('/fixtures/')) { filePath = path.join(${JSON.stringify(path.join(import.meta.dir))}, req.url); - } else if (req.url.startsWith('/js/')) { - filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..', 'public'))}, req.url); + } else if (req.url.startsWith('/.claude/')) { + filePath = path.join(${JSON.stringify(path.join(import.meta.dir, '..'))}, req.url); } else { res.writeHead(404); res.end(); return; } diff --git a/tests/fixtures/antipatterns/color-should-flag.html b/tests/fixtures/antipatterns/color-should-flag.html index af0df8aa8..764d2c237 100644 --- a/tests/fixtures/antipatterns/color-should-flag.html +++ b/tests/fixtures/antipatterns/color-should-flag.html @@ -75,6 +75,6 @@

Purple-to-indigo gradient

- + diff --git a/tests/fixtures/antipatterns/color-should-pass.html b/tests/fixtures/antipatterns/color-should-pass.html index 71afbce66..dc02dbe60 100644 --- a/tests/fixtures/antipatterns/color-should-pass.html +++ b/tests/fixtures/antipatterns/color-should-pass.html @@ -44,6 +44,6 @@

Red heading — not AI purple

Amber heading — distinctive

- + diff --git a/tests/fixtures/antipatterns/layout-should-flag.html b/tests/fixtures/antipatterns/layout-should-flag.html index 9054099aa..f0aa63bb5 100644 --- a/tests/fixtures/antipatterns/layout-should-flag.html +++ b/tests/fixtures/antipatterns/layout-should-flag.html @@ -132,6 +132,6 @@ - + diff --git a/tests/fixtures/antipatterns/layout-should-pass.html b/tests/fixtures/antipatterns/layout-should-pass.html index 02a1c75ec..a6bfe872f 100644 --- a/tests/fixtures/antipatterns/layout-should-pass.html +++ b/tests/fixtures/antipatterns/layout-should-pass.html @@ -237,6 +237,6 @@ - + diff --git a/tests/fixtures/antipatterns/legitimate-borders.html b/tests/fixtures/antipatterns/legitimate-borders.html index 4b89da377..bade3aaaa 100644 --- a/tests/fixtures/antipatterns/legitimate-borders.html +++ b/tests/fixtures/antipatterns/legitimate-borders.html @@ -108,6 +108,6 @@ Warning: Your trial expires in 3 days. Upgrade now - + diff --git a/tests/fixtures/antipatterns/linked-stylesheet.html b/tests/fixtures/antipatterns/linked-stylesheet.html index a679dd432..8c9aa1fa4 100644 --- a/tests/fixtures/antipatterns/linked-stylesheet.html +++ b/tests/fixtures/antipatterns/linked-stylesheet.html @@ -43,6 +43,6 @@

Uniform 1px border — should NOT flag.

- + diff --git a/tests/fixtures/antipatterns/partial-component.html b/tests/fixtures/antipatterns/partial-component.html index 03324748b..62c76d8e6 100644 --- a/tests/fixtures/antipatterns/partial-component.html +++ b/tests/fixtures/antipatterns/partial-component.html @@ -8,4 +8,4 @@

Card description with close font sizes.

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

Inline dark card with side-tab.

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

Shadow only. Clean.

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

A Subheading

Can you tell this is a subheading? Exactly.

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

Strong Size Hierarchy

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

Caption text is clearly distinct from body.

- +