@impeccable/detect 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/README.md +64 -0
- package/bin/cli.js +22 -0
- package/package.json +50 -0
- package/src/detect-antipatterns-browser.js +1579 -0
- package/src/detect-antipatterns.mjs +2548 -0
|
@@ -0,0 +1,2548 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Anti-Pattern Detector for Impeccable
|
|
5
|
+
*
|
|
6
|
+
* Universal file — auto-detects environment (browser vs Node) and adapts.
|
|
7
|
+
*
|
|
8
|
+
* Node usage:
|
|
9
|
+
* node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest
|
|
10
|
+
* node detect-antipatterns.mjs https://... # Puppeteer (auto)
|
|
11
|
+
* node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom)
|
|
12
|
+
* node detect-antipatterns.mjs --json # JSON output
|
|
13
|
+
*
|
|
14
|
+
* Browser usage:
|
|
15
|
+
* <script src="detect-antipatterns-browser.js"></script>
|
|
16
|
+
* Re-scan: window.impeccableScan()
|
|
17
|
+
*
|
|
18
|
+
* Exit codes: 0 = clean, 2 = findings
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// ─── Environment ────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const IS_BROWSER = typeof window !== 'undefined';
|
|
24
|
+
const IS_NODE = !IS_BROWSER;
|
|
25
|
+
|
|
26
|
+
// @browser-strip-start
|
|
27
|
+
let fs, path;
|
|
28
|
+
if (!IS_BROWSER) {
|
|
29
|
+
fs = (await import('node:fs')).default;
|
|
30
|
+
path = (await import('node:path')).default;
|
|
31
|
+
}
|
|
32
|
+
// @browser-strip-end
|
|
33
|
+
|
|
34
|
+
// ─── Section 1: Constants ───────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const SAFE_TAGS = new Set([
|
|
37
|
+
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
|
|
38
|
+
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
|
|
39
|
+
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
|
|
40
|
+
'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
|
|
41
|
+
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const OVERUSED_FONTS = new Set([
|
|
45
|
+
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const GENERIC_FONTS = new Set([
|
|
49
|
+
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
|
50
|
+
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
|
51
|
+
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
|
52
|
+
'inherit', 'initial', 'unset', 'revert',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const ANTIPATTERNS = [
|
|
56
|
+
{
|
|
57
|
+
id: 'side-tab',
|
|
58
|
+
name: 'Side-tab accent border',
|
|
59
|
+
description:
|
|
60
|
+
'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.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'border-accent-on-rounded',
|
|
64
|
+
name: 'Border accent on rounded element',
|
|
65
|
+
description:
|
|
66
|
+
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'overused-font',
|
|
70
|
+
name: 'Overused font',
|
|
71
|
+
description:
|
|
72
|
+
'Inter, Roboto, Open Sans, Lato, Montserrat, and Arial are used on millions of sites. Choose a distinctive font that gives your interface personality.',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'single-font',
|
|
76
|
+
name: 'Single font for everything',
|
|
77
|
+
description:
|
|
78
|
+
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'flat-type-hierarchy',
|
|
82
|
+
name: 'Flat type hierarchy',
|
|
83
|
+
description:
|
|
84
|
+
'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).',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'pure-black-white',
|
|
88
|
+
name: 'Pure black background',
|
|
89
|
+
description:
|
|
90
|
+
'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.',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'gray-on-color',
|
|
94
|
+
name: 'Gray text on colored background',
|
|
95
|
+
description:
|
|
96
|
+
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'low-contrast',
|
|
100
|
+
name: 'Low contrast text',
|
|
101
|
+
description:
|
|
102
|
+
'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.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'gradient-text',
|
|
106
|
+
name: 'Gradient text',
|
|
107
|
+
description:
|
|
108
|
+
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
id: 'ai-color-palette',
|
|
112
|
+
name: 'AI color palette',
|
|
113
|
+
description:
|
|
114
|
+
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'nested-cards',
|
|
118
|
+
name: 'Nested cards',
|
|
119
|
+
description:
|
|
120
|
+
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'monotonous-spacing',
|
|
124
|
+
name: 'Monotonous spacing',
|
|
125
|
+
description:
|
|
126
|
+
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'everything-centered',
|
|
130
|
+
name: 'Everything centered',
|
|
131
|
+
description:
|
|
132
|
+
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: 'bounce-easing',
|
|
136
|
+
name: 'Bounce or elastic easing',
|
|
137
|
+
description:
|
|
138
|
+
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
id: 'layout-transition',
|
|
142
|
+
name: 'Layout property animation',
|
|
143
|
+
description:
|
|
144
|
+
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'dark-glow',
|
|
148
|
+
name: 'Dark mode with glowing accents',
|
|
149
|
+
description:
|
|
150
|
+
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'line-length',
|
|
154
|
+
name: 'Line length too long',
|
|
155
|
+
description:
|
|
156
|
+
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'cramped-padding',
|
|
160
|
+
name: 'Cramped padding',
|
|
161
|
+
description:
|
|
162
|
+
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: 'tight-leading',
|
|
166
|
+
name: 'Tight line height',
|
|
167
|
+
description:
|
|
168
|
+
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'skipped-heading',
|
|
172
|
+
name: 'Skipped heading level',
|
|
173
|
+
description:
|
|
174
|
+
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'justified-text',
|
|
178
|
+
name: 'Justified text',
|
|
179
|
+
description:
|
|
180
|
+
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'tiny-text',
|
|
184
|
+
name: 'Tiny body text',
|
|
185
|
+
description:
|
|
186
|
+
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 'all-caps-body',
|
|
190
|
+
name: 'All-caps body text',
|
|
191
|
+
description:
|
|
192
|
+
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'wide-tracking',
|
|
196
|
+
name: 'Wide letter spacing on body text',
|
|
197
|
+
description:
|
|
198
|
+
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
function isNeutralColor(color) {
|
|
205
|
+
if (!color || color === 'transparent') return true;
|
|
206
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
207
|
+
if (!m) return true;
|
|
208
|
+
return (Math.max(+m[1], +m[2], +m[3]) - Math.min(+m[1], +m[2], +m[3])) < 30;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseRgb(color) {
|
|
212
|
+
if (!color || color === 'transparent') return null;
|
|
213
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
214
|
+
if (!m) return null;
|
|
215
|
+
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function relativeLuminance({ r, g, b }) {
|
|
219
|
+
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
|
|
220
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
|
221
|
+
);
|
|
222
|
+
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function contrastRatio(c1, c2) {
|
|
226
|
+
const l1 = relativeLuminance(c1);
|
|
227
|
+
const l2 = relativeLuminance(c2);
|
|
228
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseGradientColors(bgImage) {
|
|
232
|
+
if (!bgImage || !bgImage.includes('gradient')) return [];
|
|
233
|
+
return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
|
|
234
|
+
.map(m => parseRgb(m[0]))
|
|
235
|
+
.filter(Boolean);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hasChroma(c, threshold = 30) {
|
|
239
|
+
if (!c) return false;
|
|
240
|
+
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getHue(c) {
|
|
244
|
+
if (!c) return 0;
|
|
245
|
+
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
|
|
246
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
247
|
+
if (max === min) return 0;
|
|
248
|
+
const d = max - min;
|
|
249
|
+
let h;
|
|
250
|
+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
251
|
+
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
252
|
+
else h = ((r - g) / d + 4) / 6;
|
|
253
|
+
return Math.round(h * 360);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function colorToHex(c) {
|
|
257
|
+
if (!c) return '?';
|
|
258
|
+
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
function checkBorders(tag, widths, colors, radius) {
|
|
264
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
265
|
+
const findings = [];
|
|
266
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
267
|
+
|
|
268
|
+
for (const side of sides) {
|
|
269
|
+
const w = widths[side];
|
|
270
|
+
if (w < 1 || isNeutralColor(colors[side])) continue;
|
|
271
|
+
|
|
272
|
+
const otherSides = sides.filter(s => s !== side);
|
|
273
|
+
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
|
274
|
+
if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
|
|
275
|
+
|
|
276
|
+
const sn = side.toLowerCase();
|
|
277
|
+
const isSide = side === 'Left' || side === 'Right';
|
|
278
|
+
|
|
279
|
+
if (isSide) {
|
|
280
|
+
if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
281
|
+
else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
|
|
282
|
+
} else {
|
|
283
|
+
if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return findings;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function checkColors(opts) {
|
|
291
|
+
const { tag, textColor, bgColor, effectiveBg, fontSize, fontWeight, hasDirectText, bgClip, bgImage, classList } = opts;
|
|
292
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
293
|
+
const findings = [];
|
|
294
|
+
|
|
295
|
+
// Pure black background (only solid or near-solid, not semi-transparent overlays)
|
|
296
|
+
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
|
297
|
+
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (hasDirectText && textColor) {
|
|
301
|
+
// Skip background-dependent checks if we can't determine the background (e.g. gradient)
|
|
302
|
+
if (effectiveBg) {
|
|
303
|
+
// Gray on colored background
|
|
304
|
+
const textLum = relativeLuminance(textColor);
|
|
305
|
+
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
|
306
|
+
if (isGray && hasChroma(effectiveBg, 40)) {
|
|
307
|
+
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Low contrast (WCAG AA)
|
|
311
|
+
const ratio = contrastRatio(textColor, effectiveBg);
|
|
312
|
+
const isHeading = ['h1', 'h2', 'h3'].includes(tag);
|
|
313
|
+
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
|
|
314
|
+
const threshold = isLargeText ? 3.0 : 4.5;
|
|
315
|
+
if (ratio < threshold) {
|
|
316
|
+
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// AI palette: purple/violet on headings
|
|
321
|
+
if (hasChroma(textColor, 50)) {
|
|
322
|
+
const hue = getHue(textColor);
|
|
323
|
+
if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
|
|
324
|
+
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Gradient text
|
|
330
|
+
if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
|
|
331
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Tailwind class checks
|
|
335
|
+
if (classList) {
|
|
336
|
+
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
|
337
|
+
if (/\bbg-black\b/.test(classStr)) {
|
|
338
|
+
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
|
342
|
+
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
|
343
|
+
if (grayMatch && colorBgMatch) {
|
|
344
|
+
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
|
|
348
|
+
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
|
|
352
|
+
if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
|
|
353
|
+
findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
|
|
357
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return findings;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
|
|
365
|
+
if (!hasShadow && !hasBorder) return false;
|
|
366
|
+
return hasRadius || hasBg;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const LAYOUT_TRANSITION_PROPS = new Set([
|
|
370
|
+
'width', 'height', 'padding', 'margin',
|
|
371
|
+
'max-height', 'max-width', 'min-height', 'min-width',
|
|
372
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
373
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
function checkMotion(opts) {
|
|
377
|
+
const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
|
|
378
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
379
|
+
const findings = [];
|
|
380
|
+
|
|
381
|
+
// --- Bounce/elastic easing ---
|
|
382
|
+
if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
|
|
383
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
|
|
384
|
+
}
|
|
385
|
+
if (classList && /\banimate-bounce\b/.test(classList)) {
|
|
386
|
+
findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
|
|
390
|
+
if (timingFunctions) {
|
|
391
|
+
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
|
|
392
|
+
let m;
|
|
393
|
+
while ((m = bezierRe.exec(timingFunctions)) !== null) {
|
|
394
|
+
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
|
395
|
+
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
396
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- Layout property transition ---
|
|
403
|
+
if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
|
|
404
|
+
const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
|
|
405
|
+
const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
|
|
406
|
+
if (layoutFound.length > 0) {
|
|
407
|
+
findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return findings;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function checkGlow(opts) {
|
|
415
|
+
const { boxShadow, effectiveBg } = opts;
|
|
416
|
+
if (!boxShadow || boxShadow === 'none') return [];
|
|
417
|
+
if (!effectiveBg) return [];
|
|
418
|
+
|
|
419
|
+
// Only flag on dark backgrounds (luminance < 0.1)
|
|
420
|
+
const bgLum = relativeLuminance(effectiveBg);
|
|
421
|
+
if (bgLum >= 0.1) return [];
|
|
422
|
+
|
|
423
|
+
// Split multiple shadows (commas not inside parentheses)
|
|
424
|
+
const parts = boxShadow.split(/,(?![^(]*\))/);
|
|
425
|
+
for (const shadow of parts) {
|
|
426
|
+
const colorMatch = shadow.match(/rgba?\([^)]+\)/);
|
|
427
|
+
if (!colorMatch) continue;
|
|
428
|
+
const color = parseRgb(colorMatch[0]);
|
|
429
|
+
if (!color || !hasChroma(color, 30)) continue;
|
|
430
|
+
|
|
431
|
+
// Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
|
|
432
|
+
const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
|
|
433
|
+
const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
|
|
434
|
+
const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
|
|
435
|
+
.map(m => parseFloat(m[1]));
|
|
436
|
+
|
|
437
|
+
// Third value is blur (offset-x, offset-y, blur, [spread])
|
|
438
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
439
|
+
return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Regex-on-HTML checks shared between browser and Node page-level detection.
|
|
448
|
+
* These don't need DOM access, just the raw HTML string.
|
|
449
|
+
*/
|
|
450
|
+
function checkHtmlPatterns(html) {
|
|
451
|
+
const findings = [];
|
|
452
|
+
|
|
453
|
+
// --- Color ---
|
|
454
|
+
|
|
455
|
+
// Pure black background
|
|
456
|
+
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
|
457
|
+
if (pureBlackBgRe.test(html)) {
|
|
458
|
+
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// AI color palette: purple/violet
|
|
462
|
+
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
|
463
|
+
if (purpleHexRe.test(html)) {
|
|
464
|
+
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
|
465
|
+
if (purpleTextRe.test(html)) {
|
|
466
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Gradient text (background-clip: text + gradient)
|
|
471
|
+
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
|
|
472
|
+
let gm;
|
|
473
|
+
while ((gm = gradientRe.exec(html)) !== null) {
|
|
474
|
+
const start = Math.max(0, gm.index - 200);
|
|
475
|
+
const context = html.substring(start, gm.index + gm[0].length + 200);
|
|
476
|
+
if (/gradient/i.test(context)) {
|
|
477
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
|
|
482
|
+
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- Layout ---
|
|
486
|
+
|
|
487
|
+
// Monotonous spacing
|
|
488
|
+
const spacingValues = [];
|
|
489
|
+
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
|
490
|
+
let sm;
|
|
491
|
+
while ((sm = spacingRe.exec(html)) !== null) {
|
|
492
|
+
const v = parseInt(sm[1], 10);
|
|
493
|
+
if (v > 0 && v < 200) spacingValues.push(v);
|
|
494
|
+
}
|
|
495
|
+
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
|
496
|
+
while ((sm = gapRe.exec(html)) !== null) {
|
|
497
|
+
spacingValues.push(parseInt(sm[1], 10));
|
|
498
|
+
}
|
|
499
|
+
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
|
500
|
+
while ((sm = twSpaceRe.exec(html)) !== null) {
|
|
501
|
+
spacingValues.push(parseInt(sm[1], 10) * 4);
|
|
502
|
+
}
|
|
503
|
+
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
|
504
|
+
while ((sm = remSpacingRe.exec(html)) !== null) {
|
|
505
|
+
const v = Math.round(parseFloat(sm[1]) * 16);
|
|
506
|
+
if (v > 0 && v < 200) spacingValues.push(v);
|
|
507
|
+
}
|
|
508
|
+
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
|
|
509
|
+
if (roundedSpacing.length >= 10) {
|
|
510
|
+
const counts = {};
|
|
511
|
+
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
|
|
512
|
+
const maxCount = Math.max(...Object.values(counts));
|
|
513
|
+
const dominantPct = maxCount / roundedSpacing.length;
|
|
514
|
+
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
|
|
515
|
+
if (dominantPct > 0.6 && unique.length <= 3) {
|
|
516
|
+
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
517
|
+
findings.push({
|
|
518
|
+
id: 'monotonous-spacing',
|
|
519
|
+
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// --- Motion ---
|
|
525
|
+
|
|
526
|
+
// Bounce/elastic animation names
|
|
527
|
+
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
|
|
528
|
+
if (bounceRe.test(html)) {
|
|
529
|
+
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Overshoot cubic-bezier
|
|
533
|
+
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
|
|
534
|
+
let bm;
|
|
535
|
+
while ((bm = bezierRe.exec(html)) !== null) {
|
|
536
|
+
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
|
537
|
+
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
538
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Layout property transitions
|
|
544
|
+
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
|
|
545
|
+
let tm;
|
|
546
|
+
while ((tm = transRe.exec(html)) !== null) {
|
|
547
|
+
const val = tm[1].toLowerCase();
|
|
548
|
+
if (/\ball\b/.test(val)) continue;
|
|
549
|
+
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
550
|
+
if (found) {
|
|
551
|
+
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// --- Dark glow ---
|
|
557
|
+
|
|
558
|
+
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
|
|
559
|
+
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
|
|
560
|
+
if (darkBgRe.test(html) || twDarkBg.test(html)) {
|
|
561
|
+
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
|
562
|
+
let shm;
|
|
563
|
+
while ((shm = shadowRe.exec(html)) !== null) {
|
|
564
|
+
const val = shm[1];
|
|
565
|
+
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
566
|
+
if (!colorMatch) continue;
|
|
567
|
+
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
|
568
|
+
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
|
|
569
|
+
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
|
570
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
571
|
+
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return findings;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
|
|
581
|
+
|
|
582
|
+
function resolveBackground(el, win) {
|
|
583
|
+
let current = el;
|
|
584
|
+
while (current && current.nodeType === 1) {
|
|
585
|
+
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
|
586
|
+
|
|
587
|
+
// If this element has a gradient background, it's opaque but we can't determine the color
|
|
588
|
+
const bgImage = style.backgroundImage || '';
|
|
589
|
+
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let bg = parseRgb(style.backgroundColor);
|
|
594
|
+
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
|
|
595
|
+
// jsdom doesn't decompose background shorthand — parse raw style attr
|
|
596
|
+
const rawStyle = current.getAttribute?.('style') || '';
|
|
597
|
+
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
|
598
|
+
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
|
599
|
+
// Check for gradient in inline style too
|
|
600
|
+
if (/gradient/i.test(inlineBg)) return null;
|
|
601
|
+
bg = parseRgb(inlineBg);
|
|
602
|
+
if (!bg && inlineBg) {
|
|
603
|
+
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
|
|
604
|
+
if (hexMatch) {
|
|
605
|
+
const h = hexMatch[1];
|
|
606
|
+
if (h.length === 6) {
|
|
607
|
+
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
|
|
608
|
+
} else {
|
|
609
|
+
bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (bg && bg.a > 0.1) {
|
|
615
|
+
if (IS_BROWSER || bg.a >= 0.5) return bg;
|
|
616
|
+
}
|
|
617
|
+
current = current.parentElement;
|
|
618
|
+
}
|
|
619
|
+
return { r: 255, g: 255, b: 255 };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ─── Section 5: Element Adapters ────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
|
|
625
|
+
|
|
626
|
+
function checkElementBordersDOM(el) {
|
|
627
|
+
const tag = el.tagName.toLowerCase();
|
|
628
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
629
|
+
const rect = el.getBoundingClientRect();
|
|
630
|
+
if (rect.width < 20 || rect.height < 20) return [];
|
|
631
|
+
const style = getComputedStyle(el);
|
|
632
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
633
|
+
const widths = {}, colors = {};
|
|
634
|
+
for (const s of sides) {
|
|
635
|
+
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
|
636
|
+
colors[s] = style[`border${s}Color`] || '';
|
|
637
|
+
}
|
|
638
|
+
return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function checkElementColorsDOM(el) {
|
|
642
|
+
const tag = el.tagName.toLowerCase();
|
|
643
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
644
|
+
const rect = el.getBoundingClientRect();
|
|
645
|
+
if (rect.width < 10 || rect.height < 10) return [];
|
|
646
|
+
const style = getComputedStyle(el);
|
|
647
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
|
648
|
+
return checkColors({
|
|
649
|
+
tag,
|
|
650
|
+
textColor: parseRgb(style.color),
|
|
651
|
+
bgColor: parseRgb(style.backgroundColor),
|
|
652
|
+
effectiveBg: resolveBackground(el),
|
|
653
|
+
fontSize: parseFloat(style.fontSize) || 16,
|
|
654
|
+
fontWeight: parseInt(style.fontWeight) || 400,
|
|
655
|
+
hasDirectText,
|
|
656
|
+
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
657
|
+
bgImage: style.backgroundImage || '',
|
|
658
|
+
classList: el.getAttribute('class') || '',
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function checkElementMotionDOM(el) {
|
|
663
|
+
const tag = el.tagName.toLowerCase();
|
|
664
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
665
|
+
const style = getComputedStyle(el);
|
|
666
|
+
return checkMotion({
|
|
667
|
+
tag,
|
|
668
|
+
transitionProperty: style.transitionProperty || '',
|
|
669
|
+
animationName: style.animationName || '',
|
|
670
|
+
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
|
|
671
|
+
classList: el.getAttribute('class') || '',
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function checkElementGlowDOM(el) {
|
|
676
|
+
const tag = el.tagName.toLowerCase();
|
|
677
|
+
const style = getComputedStyle(el);
|
|
678
|
+
if (!style.boxShadow || style.boxShadow === 'none') return [];
|
|
679
|
+
// Use parent's background — glow radiates outward, so the surrounding context matters
|
|
680
|
+
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
|
681
|
+
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
|
682
|
+
if (!parentBg) {
|
|
683
|
+
// Gradient background — sample its colors to determine if it's dark
|
|
684
|
+
let cur = el.parentElement;
|
|
685
|
+
while (cur && cur.nodeType === 1) {
|
|
686
|
+
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
|
687
|
+
const gradColors = parseGradientColors(bgImage);
|
|
688
|
+
if (gradColors.length > 0) {
|
|
689
|
+
// Average the gradient colors
|
|
690
|
+
const avg = { r: 0, g: 0, b: 0 };
|
|
691
|
+
for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
|
|
692
|
+
avg.r = Math.round(avg.r / gradColors.length);
|
|
693
|
+
avg.g = Math.round(avg.g / gradColors.length);
|
|
694
|
+
avg.b = Math.round(avg.b / gradColors.length);
|
|
695
|
+
parentBg = avg;
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
cur = cur.parentElement;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function checkElementAIPaletteDOM(el) {
|
|
705
|
+
const style = getComputedStyle(el);
|
|
706
|
+
const findings = [];
|
|
707
|
+
|
|
708
|
+
// Check gradient backgrounds for purple/violet or cyan
|
|
709
|
+
const bgImage = style.backgroundImage || '';
|
|
710
|
+
const gradColors = parseGradientColors(bgImage);
|
|
711
|
+
for (const c of gradColors) {
|
|
712
|
+
if (hasChroma(c, 50)) {
|
|
713
|
+
const hue = getHue(c);
|
|
714
|
+
if (hue >= 260 && hue <= 310) {
|
|
715
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
if (hue >= 160 && hue <= 200) {
|
|
719
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Check for neon text (vivid cyan/purple color on dark background)
|
|
726
|
+
const textColor = parseRgb(style.color);
|
|
727
|
+
if (textColor && hasChroma(textColor, 80)) {
|
|
728
|
+
const hue = getHue(textColor);
|
|
729
|
+
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
|
730
|
+
if (isAIPalette) {
|
|
731
|
+
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
|
732
|
+
// Also check gradient parents
|
|
733
|
+
let effectiveBg = parentBg;
|
|
734
|
+
if (!effectiveBg) {
|
|
735
|
+
let cur = el.parentElement;
|
|
736
|
+
while (cur && cur.nodeType === 1) {
|
|
737
|
+
const gi = getComputedStyle(cur).backgroundImage || '';
|
|
738
|
+
const gc = parseGradientColors(gi);
|
|
739
|
+
if (gc.length > 0) {
|
|
740
|
+
const avg = { r: 0, g: 0, b: 0 };
|
|
741
|
+
for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
|
|
742
|
+
avg.r = Math.round(avg.r / gc.length);
|
|
743
|
+
avg.g = Math.round(avg.g / gc.length);
|
|
744
|
+
avg.b = Math.round(avg.b / gc.length);
|
|
745
|
+
effectiveBg = avg;
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
cur = cur.parentElement;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
|
|
752
|
+
const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
|
|
753
|
+
findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return findings;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
|
762
|
+
|
|
763
|
+
function checkElementQualityDOM(el) {
|
|
764
|
+
const tag = el.tagName.toLowerCase();
|
|
765
|
+
// Skip browser extension injected elements
|
|
766
|
+
const elId = el.id || '';
|
|
767
|
+
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return [];
|
|
768
|
+
const style = getComputedStyle(el);
|
|
769
|
+
const findings = [];
|
|
770
|
+
|
|
771
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
|
772
|
+
const textLen = el.textContent?.trim().length || 0;
|
|
773
|
+
const fontSize = parseFloat(style.fontSize) || 16;
|
|
774
|
+
const rect = el.getBoundingClientRect();
|
|
775
|
+
|
|
776
|
+
// --- Line length too long ---
|
|
777
|
+
// Only flag if text is long enough to actually fill the line (>80 chars)
|
|
778
|
+
if (hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > 80) {
|
|
779
|
+
const charsPerLine = rect.width / (fontSize * 0.5);
|
|
780
|
+
if (charsPerLine > 85) {
|
|
781
|
+
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <80)` });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// --- Cramped padding (skip small elements like labels/badges) ---
|
|
786
|
+
if (hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
|
787
|
+
const borders = {
|
|
788
|
+
top: parseFloat(style.borderTopWidth) || 0,
|
|
789
|
+
right: parseFloat(style.borderRightWidth) || 0,
|
|
790
|
+
bottom: parseFloat(style.borderBottomWidth) || 0,
|
|
791
|
+
left: parseFloat(style.borderLeftWidth) || 0,
|
|
792
|
+
};
|
|
793
|
+
// Need at least 2 borders (a container), or a non-transparent background
|
|
794
|
+
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
|
795
|
+
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
796
|
+
if (borderCount >= 2 || hasBg) {
|
|
797
|
+
// Only check padding on sides that have borders or where bg creates containment
|
|
798
|
+
const paddings = [];
|
|
799
|
+
if (hasBg || borders.top > 0) paddings.push(parseFloat(style.paddingTop) || 0);
|
|
800
|
+
if (hasBg || borders.right > 0) paddings.push(parseFloat(style.paddingRight) || 0);
|
|
801
|
+
if (hasBg || borders.bottom > 0) paddings.push(parseFloat(style.paddingBottom) || 0);
|
|
802
|
+
if (hasBg || borders.left > 0) paddings.push(parseFloat(style.paddingLeft) || 0);
|
|
803
|
+
if (paddings.length > 0) {
|
|
804
|
+
const minPad = Math.min(...paddings);
|
|
805
|
+
if (minPad < 8) {
|
|
806
|
+
findings.push({ id: 'cramped-padding', snippet: `${minPad}px padding (need >=8px)` });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- Tight line height ---
|
|
813
|
+
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
|
814
|
+
const lineHeight = parseFloat(style.lineHeight);
|
|
815
|
+
if (lineHeight && lineHeight !== NaN) {
|
|
816
|
+
const ratio = lineHeight / fontSize;
|
|
817
|
+
if (ratio < 1.3 && ratio > 0) {
|
|
818
|
+
findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
// --- Justified text (without hyphens) ---
|
|
825
|
+
if (hasDirectText && style.textAlign === 'justify') {
|
|
826
|
+
const hyphens = style.hyphens || style.webkitHyphens || '';
|
|
827
|
+
if (hyphens !== 'auto') {
|
|
828
|
+
findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// --- Tiny body text ---
|
|
833
|
+
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
|
834
|
+
if (!['sub', 'sup', 'code', 'kbd', 'samp', 'var'].includes(tag)) {
|
|
835
|
+
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// --- All-caps body text ---
|
|
840
|
+
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
|
841
|
+
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
|
842
|
+
findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// --- Wide letter spacing on body text ---
|
|
847
|
+
if (hasDirectText && textLen > 20) {
|
|
848
|
+
const tracking = parseFloat(style.letterSpacing);
|
|
849
|
+
if (tracking > 0 && style.textTransform !== 'uppercase') {
|
|
850
|
+
const trackingEm = tracking / fontSize;
|
|
851
|
+
if (trackingEm > 0.05) {
|
|
852
|
+
findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return findings;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function checkPageQualityDOM() {
|
|
861
|
+
const findings = [];
|
|
862
|
+
|
|
863
|
+
// --- Skipped heading levels ---
|
|
864
|
+
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
865
|
+
let prevLevel = 0;
|
|
866
|
+
for (const h of headings) {
|
|
867
|
+
const level = parseInt(h.tagName[1]);
|
|
868
|
+
if (prevLevel > 0 && level > prevLevel + 1) {
|
|
869
|
+
findings.push({ type: 'skipped-heading', detail: `h${prevLevel} followed by h${level} (missing h${prevLevel + 1})` });
|
|
870
|
+
}
|
|
871
|
+
prevLevel = level;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
return findings;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Node adapters — take pre-extracted jsdom computed style
|
|
878
|
+
|
|
879
|
+
function checkElementBorders(tag, style) {
|
|
880
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
881
|
+
const widths = {}, colors = {};
|
|
882
|
+
for (const s of sides) {
|
|
883
|
+
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
|
884
|
+
colors[s] = style[`border${s}Color`] || '';
|
|
885
|
+
}
|
|
886
|
+
return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function checkElementColors(el, style, tag, window) {
|
|
890
|
+
const hasText = el.textContent?.trim().length > 0;
|
|
891
|
+
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
|
892
|
+
|
|
893
|
+
return checkColors({
|
|
894
|
+
tag,
|
|
895
|
+
textColor: parseRgb(style.color),
|
|
896
|
+
bgColor: parseRgb(style.backgroundColor),
|
|
897
|
+
effectiveBg: resolveBackground(el, window),
|
|
898
|
+
fontSize: parseFloat(style.fontSize) || 16,
|
|
899
|
+
fontWeight: parseInt(style.fontWeight) || 400,
|
|
900
|
+
hasDirectText,
|
|
901
|
+
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
902
|
+
bgImage: style.backgroundImage || '',
|
|
903
|
+
classList: el.getAttribute?.('class') || el.className || '',
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function checkElementMotion(tag, style) {
|
|
908
|
+
return checkMotion({
|
|
909
|
+
tag,
|
|
910
|
+
transitionProperty: style.transitionProperty || '',
|
|
911
|
+
animationName: style.animationName || '',
|
|
912
|
+
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
|
|
913
|
+
classList: '',
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function checkElementGlow(tag, style, effectiveBg) {
|
|
918
|
+
if (!style.boxShadow || style.boxShadow === 'none') return [];
|
|
919
|
+
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
// Browser page-level checks — use document/getComputedStyle globals
|
|
925
|
+
|
|
926
|
+
function checkTypography() {
|
|
927
|
+
const findings = [];
|
|
928
|
+
const fonts = new Set();
|
|
929
|
+
const overusedFound = new Set();
|
|
930
|
+
|
|
931
|
+
for (const sheet of document.styleSheets) {
|
|
932
|
+
let rules;
|
|
933
|
+
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
|
934
|
+
if (!rules) continue;
|
|
935
|
+
for (const rule of rules) {
|
|
936
|
+
if (rule.type !== 1) continue;
|
|
937
|
+
const ff = rule.style?.fontFamily;
|
|
938
|
+
if (!ff) continue;
|
|
939
|
+
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
940
|
+
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
941
|
+
if (primary) {
|
|
942
|
+
fonts.add(primary);
|
|
943
|
+
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const html = document.documentElement.outerHTML;
|
|
949
|
+
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
|
950
|
+
let m;
|
|
951
|
+
while ((m = gfRe.exec(html)) !== null) {
|
|
952
|
+
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) {
|
|
953
|
+
fonts.add(f);
|
|
954
|
+
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
for (const font of overusedFound) {
|
|
959
|
+
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
|
|
960
|
+
}
|
|
961
|
+
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
|
|
962
|
+
findings.push({ type: 'single-font', detail: `only font used is ${[...fonts][0]}` });
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const sizes = new Set();
|
|
966
|
+
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
|
|
967
|
+
const fs = parseFloat(getComputedStyle(el).fontSize);
|
|
968
|
+
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
|
|
969
|
+
}
|
|
970
|
+
if (sizes.size >= 3) {
|
|
971
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
972
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
973
|
+
if (ratio < 2.0) {
|
|
974
|
+
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return findings;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function isCardLikeDOM(el) {
|
|
982
|
+
const tag = el.tagName.toLowerCase();
|
|
983
|
+
if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
|
|
984
|
+
const style = getComputedStyle(el);
|
|
985
|
+
const cls = el.getAttribute('class') || '';
|
|
986
|
+
const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
|
|
987
|
+
const hasBorder = /\bborder\b/.test(cls);
|
|
988
|
+
const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
|
|
989
|
+
const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
|
|
990
|
+
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function checkLayout() {
|
|
994
|
+
const findings = [];
|
|
995
|
+
const flaggedEls = new Set();
|
|
996
|
+
|
|
997
|
+
for (const el of document.querySelectorAll('*')) {
|
|
998
|
+
if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
|
|
999
|
+
const cls = el.getAttribute('class') || '';
|
|
1000
|
+
const style = getComputedStyle(el);
|
|
1001
|
+
if (style.position === 'absolute' || style.position === 'fixed') continue;
|
|
1002
|
+
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
|
1003
|
+
if ((el.textContent?.trim().length || 0) < 10) continue;
|
|
1004
|
+
const rect = el.getBoundingClientRect();
|
|
1005
|
+
if (rect.width < 50 || rect.height < 30) continue;
|
|
1006
|
+
|
|
1007
|
+
let parent = el.parentElement;
|
|
1008
|
+
while (parent) {
|
|
1009
|
+
if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
|
|
1010
|
+
parent = parent.parentElement;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
for (const el of flaggedEls) {
|
|
1015
|
+
let isAncestor = false;
|
|
1016
|
+
for (const other of flaggedEls) {
|
|
1017
|
+
if (other !== el && el.contains(other)) { isAncestor = true; break; }
|
|
1018
|
+
}
|
|
1019
|
+
if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
return findings;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Node page-level checks — take document/window as parameters
|
|
1026
|
+
|
|
1027
|
+
function checkPageTypography(doc, win) {
|
|
1028
|
+
const findings = [];
|
|
1029
|
+
|
|
1030
|
+
const fonts = new Set();
|
|
1031
|
+
const overusedFound = new Set();
|
|
1032
|
+
|
|
1033
|
+
for (const sheet of doc.styleSheets) {
|
|
1034
|
+
let rules;
|
|
1035
|
+
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
|
1036
|
+
if (!rules) continue;
|
|
1037
|
+
for (const rule of rules) {
|
|
1038
|
+
if (rule.type !== 1) continue;
|
|
1039
|
+
const ff = rule.style?.fontFamily;
|
|
1040
|
+
if (!ff) continue;
|
|
1041
|
+
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
1042
|
+
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
1043
|
+
if (primary) {
|
|
1044
|
+
fonts.add(primary);
|
|
1045
|
+
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Check Google Fonts links in HTML
|
|
1051
|
+
const html = doc.documentElement?.outerHTML || '';
|
|
1052
|
+
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
|
1053
|
+
let m;
|
|
1054
|
+
while ((m = gfRe.exec(html)) !== null) {
|
|
1055
|
+
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
|
1056
|
+
for (const f of families) {
|
|
1057
|
+
fonts.add(f);
|
|
1058
|
+
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
|
1063
|
+
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
1064
|
+
let fm;
|
|
1065
|
+
while ((fm = ffRe.exec(html)) !== null) {
|
|
1066
|
+
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
|
1067
|
+
if (f && !GENERIC_FONTS.has(f)) {
|
|
1068
|
+
fonts.add(f);
|
|
1069
|
+
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
for (const font of overusedFound) {
|
|
1075
|
+
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Single font
|
|
1079
|
+
if (fonts.size === 1) {
|
|
1080
|
+
const els = doc.querySelectorAll('*');
|
|
1081
|
+
if (els.length >= 20) {
|
|
1082
|
+
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Flat type hierarchy
|
|
1087
|
+
const sizes = new Set();
|
|
1088
|
+
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
|
1089
|
+
for (const el of textEls) {
|
|
1090
|
+
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
|
|
1091
|
+
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
|
|
1092
|
+
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
|
1093
|
+
}
|
|
1094
|
+
if (sizes.size >= 3) {
|
|
1095
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
1096
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
1097
|
+
if (ratio < 2.0) {
|
|
1098
|
+
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return findings;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function isCardLike(el, win) {
|
|
1106
|
+
const tag = el.tagName.toLowerCase();
|
|
1107
|
+
if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
|
|
1108
|
+
|
|
1109
|
+
const style = win.getComputedStyle(el);
|
|
1110
|
+
const rawStyle = el.getAttribute?.('style') || '';
|
|
1111
|
+
const cls = el.getAttribute?.('class') || '';
|
|
1112
|
+
|
|
1113
|
+
const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
|
|
1114
|
+
/\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
|
|
1115
|
+
const hasBorder = /\bborder\b/.test(cls);
|
|
1116
|
+
const hasRadius = (parseFloat(style.borderRadius) || 0) > 0 ||
|
|
1117
|
+
/\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
|
|
1118
|
+
const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
|
|
1119
|
+
/background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
|
|
1120
|
+
|
|
1121
|
+
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function checkPageLayout(doc, win) {
|
|
1125
|
+
const findings = [];
|
|
1126
|
+
|
|
1127
|
+
// Nested cards
|
|
1128
|
+
const allEls = doc.querySelectorAll('*');
|
|
1129
|
+
const flaggedEls = new Set();
|
|
1130
|
+
for (const el of allEls) {
|
|
1131
|
+
if (!isCardLike(el, win)) continue;
|
|
1132
|
+
if (flaggedEls.has(el)) continue;
|
|
1133
|
+
|
|
1134
|
+
const tag = el.tagName.toLowerCase();
|
|
1135
|
+
const cls = el.getAttribute?.('class') || '';
|
|
1136
|
+
const rawStyle = el.getAttribute?.('style') || '';
|
|
1137
|
+
|
|
1138
|
+
if (['pre', 'code'].includes(tag)) continue;
|
|
1139
|
+
if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
|
|
1140
|
+
if ((el.textContent?.trim().length || 0) < 10) continue;
|
|
1141
|
+
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
|
1142
|
+
|
|
1143
|
+
// Walk up to find card-like ancestor
|
|
1144
|
+
let parent = el.parentElement;
|
|
1145
|
+
while (parent) {
|
|
1146
|
+
if (isCardLike(parent, win)) {
|
|
1147
|
+
flaggedEls.add(el);
|
|
1148
|
+
break;
|
|
1149
|
+
}
|
|
1150
|
+
parent = parent.parentElement;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Only report innermost nested cards
|
|
1155
|
+
for (const el of flaggedEls) {
|
|
1156
|
+
let isAncestorOfFlagged = false;
|
|
1157
|
+
for (const other of flaggedEls) {
|
|
1158
|
+
if (other !== el && el.contains(other)) {
|
|
1159
|
+
isAncestorOfFlagged = true;
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (!isAncestorOfFlagged) {
|
|
1164
|
+
findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Everything centered
|
|
1169
|
+
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
|
|
1170
|
+
let centeredCount = 0;
|
|
1171
|
+
let totalText = 0;
|
|
1172
|
+
for (const el of textEls) {
|
|
1173
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
|
|
1174
|
+
if (!hasDirectText) continue;
|
|
1175
|
+
totalText++;
|
|
1176
|
+
|
|
1177
|
+
let cur = el;
|
|
1178
|
+
let isCentered = false;
|
|
1179
|
+
while (cur && cur.nodeType === 1) {
|
|
1180
|
+
const rawStyle = cur.getAttribute?.('style') || '';
|
|
1181
|
+
const cls = cur.getAttribute?.('class') || '';
|
|
1182
|
+
if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
|
|
1183
|
+
isCentered = true;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
if (cur.tagName === 'BODY') break;
|
|
1187
|
+
cur = cur.parentElement;
|
|
1188
|
+
}
|
|
1189
|
+
if (isCentered) centeredCount++;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (totalText >= 5 && centeredCount / totalText > 0.7) {
|
|
1193
|
+
findings.push({
|
|
1194
|
+
id: 'everything-centered',
|
|
1195
|
+
snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
return findings;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
|
|
1203
|
+
|
|
1204
|
+
if (IS_BROWSER) {
|
|
1205
|
+
const LABEL_BG = 'oklch(55% 0.25 350)';
|
|
1206
|
+
const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
|
|
1207
|
+
|
|
1208
|
+
// Inject hover styles via CSS (more reliable than JS event listeners)
|
|
1209
|
+
const styleEl = document.createElement('style');
|
|
1210
|
+
styleEl.textContent = `
|
|
1211
|
+
@keyframes impeccable-reveal {
|
|
1212
|
+
from { opacity: 0; outline-color: transparent; }
|
|
1213
|
+
to { opacity: 1; outline-color: ${OUTLINE_COLOR}; }
|
|
1214
|
+
}
|
|
1215
|
+
.impeccable-overlay:not(.impeccable-banner) {
|
|
1216
|
+
pointer-events: none;
|
|
1217
|
+
outline: 2px solid ${OUTLINE_COLOR};
|
|
1218
|
+
border-radius: 4px;
|
|
1219
|
+
transition: outline-color 0.3s ease;
|
|
1220
|
+
animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
1221
|
+
animation-play-state: paused;
|
|
1222
|
+
}
|
|
1223
|
+
.impeccable-overlay.impeccable-visible {
|
|
1224
|
+
animation-play-state: running;
|
|
1225
|
+
}
|
|
1226
|
+
.impeccable-overlay.impeccable-hover {
|
|
1227
|
+
outline-color: rgba(0,0,0,0.85);
|
|
1228
|
+
z-index: 100001 !important;
|
|
1229
|
+
}
|
|
1230
|
+
.impeccable-label-name,
|
|
1231
|
+
.impeccable-label-detail {
|
|
1232
|
+
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
1233
|
+
}
|
|
1234
|
+
.impeccable-label-detail {
|
|
1235
|
+
position: absolute; top: 100%; left: 0;
|
|
1236
|
+
}
|
|
1237
|
+
.impeccable-overlay.impeccable-hover .impeccable-label-name,
|
|
1238
|
+
.impeccable-overlay.impeccable-hover .impeccable-label-detail {
|
|
1239
|
+
transform: translateY(-100%);
|
|
1240
|
+
}
|
|
1241
|
+
.impeccable-hidden .impeccable-overlay:not(.impeccable-banner) {
|
|
1242
|
+
display: none !important;
|
|
1243
|
+
}
|
|
1244
|
+
`;
|
|
1245
|
+
(document.head || document.documentElement).appendChild(styleEl);
|
|
1246
|
+
|
|
1247
|
+
const overlays = [];
|
|
1248
|
+
const TYPE_LABELS = {};
|
|
1249
|
+
for (const ap of ANTIPATTERNS) {
|
|
1250
|
+
TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function isInFixedContext(el) {
|
|
1254
|
+
let p = el;
|
|
1255
|
+
while (p && p !== document.body) {
|
|
1256
|
+
if (getComputedStyle(p).position === 'fixed') return true;
|
|
1257
|
+
p = p.parentElement;
|
|
1258
|
+
}
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function positionOverlay(overlay) {
|
|
1263
|
+
const el = overlay._targetEl;
|
|
1264
|
+
if (!el) return;
|
|
1265
|
+
const rect = el.getBoundingClientRect();
|
|
1266
|
+
if (overlay._isFixed) {
|
|
1267
|
+
// Viewport-relative coords for fixed targets
|
|
1268
|
+
overlay.style.top = `${rect.top - 2}px`;
|
|
1269
|
+
overlay.style.left = `${rect.left - 2}px`;
|
|
1270
|
+
} else {
|
|
1271
|
+
// Document-relative coords for normal targets
|
|
1272
|
+
overlay.style.top = `${rect.top + scrollY - 2}px`;
|
|
1273
|
+
overlay.style.left = `${rect.left + scrollX - 2}px`;
|
|
1274
|
+
}
|
|
1275
|
+
overlay.style.width = `${rect.width + 4}px`;
|
|
1276
|
+
overlay.style.height = `${rect.height + 4}px`;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function repositionOverlays() {
|
|
1280
|
+
for (const o of overlays) {
|
|
1281
|
+
if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
|
|
1282
|
+
positionOverlay(o);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
let resizeRAF;
|
|
1287
|
+
const onResize = () => {
|
|
1288
|
+
cancelAnimationFrame(resizeRAF);
|
|
1289
|
+
resizeRAF = requestAnimationFrame(repositionOverlays);
|
|
1290
|
+
};
|
|
1291
|
+
window.addEventListener('resize', onResize);
|
|
1292
|
+
|
|
1293
|
+
// Track target element visibility via IntersectionObserver.
|
|
1294
|
+
// Uses a huge rootMargin so all *rendered* elements count as intersecting,
|
|
1295
|
+
// while display:none / closed <details> / hidden modals etc. do not.
|
|
1296
|
+
// This is event-driven -- no polling needed.
|
|
1297
|
+
let overlayIndex = 0;
|
|
1298
|
+
const visibilityObserver = new IntersectionObserver((entries) => {
|
|
1299
|
+
for (const entry of entries) {
|
|
1300
|
+
const overlay = entry.target._impeccableOverlay;
|
|
1301
|
+
if (!overlay) continue;
|
|
1302
|
+
if (entry.isIntersecting) {
|
|
1303
|
+
overlay.style.display = '';
|
|
1304
|
+
positionOverlay(overlay);
|
|
1305
|
+
if (!overlay._revealed) {
|
|
1306
|
+
overlay._revealed = true;
|
|
1307
|
+
overlay.style.animationDelay = `${(overlay._staggerIndex || 0) * 80}ms`;
|
|
1308
|
+
requestAnimationFrame(() => overlay.classList.add('impeccable-visible'));
|
|
1309
|
+
}
|
|
1310
|
+
} else {
|
|
1311
|
+
overlay.style.display = 'none';
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
}, { rootMargin: '99999px' });
|
|
1315
|
+
|
|
1316
|
+
// Reposition overlays after CSS transitions end (e.g. reveal animations).
|
|
1317
|
+
// Listens at document level so it catches transitions on ancestor elements
|
|
1318
|
+
// (the transform may be on a parent, not the flagged element itself).
|
|
1319
|
+
document.addEventListener('transitionend', (e) => {
|
|
1320
|
+
if (e.propertyName !== 'transform') return;
|
|
1321
|
+
for (const o of overlays) {
|
|
1322
|
+
if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
|
|
1323
|
+
if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
|
|
1324
|
+
positionOverlay(o);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
const highlight = function(el, findings) {
|
|
1330
|
+
const fixed = isInFixedContext(el);
|
|
1331
|
+
const rect = el.getBoundingClientRect();
|
|
1332
|
+
const outline = document.createElement('div');
|
|
1333
|
+
outline.className = 'impeccable-overlay';
|
|
1334
|
+
outline._targetEl = el;
|
|
1335
|
+
outline._isFixed = fixed;
|
|
1336
|
+
Object.assign(outline.style, {
|
|
1337
|
+
position: fixed ? 'fixed' : 'absolute',
|
|
1338
|
+
top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
|
|
1339
|
+
left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
|
|
1340
|
+
width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
|
|
1341
|
+
zIndex: '99999', boxSizing: 'border-box',
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
const typeText = findings.map(f => TYPE_LABELS[f.type || f.id] || f.type || f.id).join(', ');
|
|
1345
|
+
const detailText = findings.map(f => f.detail || f.snippet).join(' | ');
|
|
1346
|
+
|
|
1347
|
+
const label = document.createElement('div');
|
|
1348
|
+
label.className = 'impeccable-label';
|
|
1349
|
+
Object.assign(label.style, {
|
|
1350
|
+
position: 'absolute', top: '-22px', left: '0',
|
|
1351
|
+
clipPath: 'inset(0 -999px)',
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
const rowBase = {
|
|
1355
|
+
padding: '2px 8px', borderRadius: '3px', whiteSpace: 'nowrap',
|
|
1356
|
+
fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
|
|
1357
|
+
color: 'white', lineHeight: '16px',
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1360
|
+
const nameRow = document.createElement('div');
|
|
1361
|
+
nameRow.className = 'impeccable-label-name';
|
|
1362
|
+
nameRow.textContent = typeText;
|
|
1363
|
+
Object.assign(nameRow.style, { ...rowBase, background: LABEL_BG, fontFamily: 'system-ui, sans-serif' });
|
|
1364
|
+
label.appendChild(nameRow);
|
|
1365
|
+
|
|
1366
|
+
const detailRow = document.createElement('div');
|
|
1367
|
+
detailRow.className = 'impeccable-label-detail';
|
|
1368
|
+
detailRow.textContent = detailText;
|
|
1369
|
+
Object.assign(detailRow.style, { ...rowBase, background: 'rgba(0,0,0,0.85)', fontFamily: 'ui-monospace, monospace', fontWeight: '400' });
|
|
1370
|
+
label.appendChild(detailRow);
|
|
1371
|
+
|
|
1372
|
+
outline.appendChild(label);
|
|
1373
|
+
|
|
1374
|
+
// Start hidden; the IntersectionObserver will show it once the target is rendered
|
|
1375
|
+
outline.style.display = 'none';
|
|
1376
|
+
outline._staggerIndex = overlayIndex++;
|
|
1377
|
+
el._impeccableOverlay = outline;
|
|
1378
|
+
visibilityObserver.observe(el);
|
|
1379
|
+
|
|
1380
|
+
// Drive hover state from the target element so pointer events pass through
|
|
1381
|
+
el.addEventListener('mouseenter', () => outline.classList.add('impeccable-hover'));
|
|
1382
|
+
el.addEventListener('mouseleave', () => outline.classList.remove('impeccable-hover'));
|
|
1383
|
+
|
|
1384
|
+
document.body.appendChild(outline);
|
|
1385
|
+
overlays.push(outline);
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
const showPageBanner = function(findings) {
|
|
1389
|
+
if (!findings.length) return;
|
|
1390
|
+
const banner = document.createElement('div');
|
|
1391
|
+
banner.className = 'impeccable-overlay impeccable-banner';
|
|
1392
|
+
Object.assign(banner.style, {
|
|
1393
|
+
position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
|
|
1394
|
+
background: LABEL_BG, color: 'white',
|
|
1395
|
+
fontFamily: 'system-ui, sans-serif', fontSize: '13px',
|
|
1396
|
+
display: 'flex', alignItems: 'center', pointerEvents: 'auto',
|
|
1397
|
+
height: '36px', overflow: 'hidden', maxWidth: '100vw',
|
|
1398
|
+
transform: 'translateY(-100%)',
|
|
1399
|
+
transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
|
1400
|
+
});
|
|
1401
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
1402
|
+
banner.style.transform = 'translateY(0)';
|
|
1403
|
+
}));
|
|
1404
|
+
|
|
1405
|
+
// Scrollable findings area
|
|
1406
|
+
const scrollArea = document.createElement('div');
|
|
1407
|
+
Object.assign(scrollArea.style, {
|
|
1408
|
+
flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
|
|
1409
|
+
display: 'flex', gap: '8px', alignItems: 'center',
|
|
1410
|
+
padding: '0 12px', scrollSnapType: 'x mandatory',
|
|
1411
|
+
scrollbarWidth: 'none',
|
|
1412
|
+
});
|
|
1413
|
+
for (const f of findings) {
|
|
1414
|
+
const tag = document.createElement('span');
|
|
1415
|
+
tag.textContent = `${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
|
|
1416
|
+
Object.assign(tag.style, {
|
|
1417
|
+
background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
|
|
1418
|
+
borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
|
|
1419
|
+
whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
|
|
1420
|
+
});
|
|
1421
|
+
scrollArea.appendChild(tag);
|
|
1422
|
+
}
|
|
1423
|
+
banner.appendChild(scrollArea);
|
|
1424
|
+
|
|
1425
|
+
// Controls area (always visible on the right)
|
|
1426
|
+
const controls = document.createElement('div');
|
|
1427
|
+
Object.assign(controls.style, {
|
|
1428
|
+
display: 'flex', alignItems: 'center', gap: '2px',
|
|
1429
|
+
padding: '0 8px', flexShrink: '0',
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
// Toggle visibility button
|
|
1433
|
+
const toggle = document.createElement('button');
|
|
1434
|
+
toggle.textContent = '\u25C9'; // circle with dot (visible state)
|
|
1435
|
+
toggle.title = 'Toggle overlay visibility';
|
|
1436
|
+
Object.assign(toggle.style, {
|
|
1437
|
+
background: 'none', border: 'none',
|
|
1438
|
+
color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
|
|
1439
|
+
opacity: '0.85', transition: 'opacity 0.15s',
|
|
1440
|
+
});
|
|
1441
|
+
let overlaysVisible = true;
|
|
1442
|
+
toggle.addEventListener('click', () => {
|
|
1443
|
+
overlaysVisible = !overlaysVisible;
|
|
1444
|
+
document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
|
|
1445
|
+
toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
|
|
1446
|
+
toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
|
|
1447
|
+
});
|
|
1448
|
+
controls.appendChild(toggle);
|
|
1449
|
+
|
|
1450
|
+
// Close button
|
|
1451
|
+
const close = document.createElement('button');
|
|
1452
|
+
close.textContent = '\u00d7';
|
|
1453
|
+
close.title = 'Dismiss banner';
|
|
1454
|
+
Object.assign(close.style, {
|
|
1455
|
+
background: 'none', border: 'none',
|
|
1456
|
+
color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
|
|
1457
|
+
});
|
|
1458
|
+
close.addEventListener('click', () => banner.remove());
|
|
1459
|
+
controls.appendChild(close);
|
|
1460
|
+
|
|
1461
|
+
banner.appendChild(controls);
|
|
1462
|
+
document.body.appendChild(banner);
|
|
1463
|
+
overlays.push(banner);
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
const printSummary = function(allFindings) {
|
|
1467
|
+
if (allFindings.length === 0) {
|
|
1468
|
+
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
console.group(
|
|
1472
|
+
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
|
|
1473
|
+
'color: oklch(60% 0.25 350); font-weight: bold'
|
|
1474
|
+
);
|
|
1475
|
+
for (const { el, findings } of allFindings) {
|
|
1476
|
+
for (const f of findings) {
|
|
1477
|
+
console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
|
|
1478
|
+
'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
console.groupEnd();
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
const scan = function() {
|
|
1485
|
+
for (const o of overlays) o.remove();
|
|
1486
|
+
overlays.length = 0;
|
|
1487
|
+
visibilityObserver.disconnect();
|
|
1488
|
+
const allFindings = [];
|
|
1489
|
+
|
|
1490
|
+
for (const el of document.querySelectorAll('*')) {
|
|
1491
|
+
if (el.classList.contains('impeccable-overlay') ||
|
|
1492
|
+
el.classList.contains('impeccable-label') ||
|
|
1493
|
+
el.classList.contains('impeccable-tooltip')) continue;
|
|
1494
|
+
// Skip browser extension elements (Claude, etc.)
|
|
1495
|
+
const elId = el.id || '';
|
|
1496
|
+
if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
|
|
1497
|
+
// Skip html/body -- page-level findings go in the banner, not a full-page overlay
|
|
1498
|
+
if (el === document.body || el === document.documentElement) continue;
|
|
1499
|
+
|
|
1500
|
+
const findings = [
|
|
1501
|
+
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1502
|
+
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1503
|
+
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1504
|
+
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1505
|
+
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1506
|
+
...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1507
|
+
];
|
|
1508
|
+
|
|
1509
|
+
if (findings.length > 0) {
|
|
1510
|
+
highlight(el, findings);
|
|
1511
|
+
allFindings.push({ el, findings });
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const pageLevelFindings = [];
|
|
1516
|
+
|
|
1517
|
+
const typoFindings = checkTypography();
|
|
1518
|
+
if (typoFindings.length > 0) {
|
|
1519
|
+
pageLevelFindings.push(...typoFindings);
|
|
1520
|
+
allFindings.push({ el: document.body, findings: typoFindings });
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const layoutFindings = checkLayout();
|
|
1524
|
+
for (const f of layoutFindings) {
|
|
1525
|
+
const el = f.el || document.body;
|
|
1526
|
+
delete f.el;
|
|
1527
|
+
// Merge into existing overlay if this element already has one
|
|
1528
|
+
const existing = el._impeccableOverlay;
|
|
1529
|
+
if (existing) {
|
|
1530
|
+
const nameRow = existing.querySelector('.impeccable-label-name');
|
|
1531
|
+
const detailRow = existing.querySelector('.impeccable-label-detail');
|
|
1532
|
+
const newType = TYPE_LABELS[f.type] || f.type;
|
|
1533
|
+
if (nameRow) nameRow.textContent += ', ' + newType;
|
|
1534
|
+
if (detailRow) detailRow.textContent += ' | ' + (f.detail || '');
|
|
1535
|
+
} else {
|
|
1536
|
+
highlight(el, [f]);
|
|
1537
|
+
}
|
|
1538
|
+
allFindings.push({ el, findings: [f] });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Page-level quality checks (headings, etc.)
|
|
1542
|
+
const qualityFindings = checkPageQualityDOM();
|
|
1543
|
+
if (qualityFindings.length > 0) {
|
|
1544
|
+
pageLevelFindings.push(...qualityFindings);
|
|
1545
|
+
allFindings.push({ el: document.body, findings: qualityFindings });
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Regex-on-HTML checks (shared with Node)
|
|
1549
|
+
const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
|
|
1550
|
+
if (htmlPatternFindings.length > 0) {
|
|
1551
|
+
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet }));
|
|
1552
|
+
pageLevelFindings.push(...mapped);
|
|
1553
|
+
allFindings.push({ el: document.body, findings: mapped });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
if (pageLevelFindings.length > 0) {
|
|
1557
|
+
showPageBanner(pageLevelFindings);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
printSummary(allFindings);
|
|
1561
|
+
return allFindings;
|
|
1562
|
+
};
|
|
1563
|
+
|
|
1564
|
+
if (document.readyState === 'loading') {
|
|
1565
|
+
document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
|
|
1566
|
+
} else {
|
|
1567
|
+
setTimeout(scan, 100);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
window.impeccableScan = scan;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ─── Section 8: Node Engine ─────────────────────────────────────────────────
|
|
1574
|
+
// @browser-strip-start
|
|
1575
|
+
|
|
1576
|
+
function getAP(id) {
|
|
1577
|
+
return ANTIPATTERNS.find(a => a.id === id);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
function finding(id, filePath, snippet, line = 0) {
|
|
1581
|
+
const ap = getAP(id);
|
|
1582
|
+
return { antipattern: id, name: ap.name, description: ap.description, file: filePath, line, snippet };
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/** Check if content looks like a full page (not a component/partial) */
|
|
1586
|
+
function isFullPage(content) {
|
|
1587
|
+
const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
|
|
1588
|
+
return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// ---------------------------------------------------------------------------
|
|
1592
|
+
// jsdom detection (default for HTML files)
|
|
1593
|
+
// ---------------------------------------------------------------------------
|
|
1594
|
+
|
|
1595
|
+
async function detectHtml(filePath) {
|
|
1596
|
+
let JSDOM;
|
|
1597
|
+
try {
|
|
1598
|
+
({ JSDOM } = await import('jsdom'));
|
|
1599
|
+
} catch {
|
|
1600
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1601
|
+
return detectText(content, filePath);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const html = fs.readFileSync(filePath, 'utf-8');
|
|
1605
|
+
const resolvedPath = path.resolve(filePath);
|
|
1606
|
+
const fileDir = path.dirname(resolvedPath);
|
|
1607
|
+
|
|
1608
|
+
// Inline linked local stylesheets so jsdom can see them
|
|
1609
|
+
let processedHtml = html;
|
|
1610
|
+
const linkRes = [
|
|
1611
|
+
/<link[^>]+rel=["']stylesheet["'][^>]*href=["']([^"']+)["'][^>]*>/gi,
|
|
1612
|
+
/<link[^>]+href=["']([^"']+)["'][^>]*rel=["']stylesheet["'][^>]*>/gi,
|
|
1613
|
+
];
|
|
1614
|
+
for (const re of linkRes) {
|
|
1615
|
+
let m;
|
|
1616
|
+
while ((m = re.exec(html)) !== null) {
|
|
1617
|
+
const href = m[1];
|
|
1618
|
+
if (/^(https?:)?\/\//.test(href)) continue;
|
|
1619
|
+
const cssPath = path.resolve(fileDir, href);
|
|
1620
|
+
try {
|
|
1621
|
+
const css = fs.readFileSync(cssPath, 'utf-8');
|
|
1622
|
+
processedHtml = processedHtml.replace(m[0], `<style>/* ${href} */\n${css}\n</style>`);
|
|
1623
|
+
} catch { /* skip unreadable */ }
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
const dom = new JSDOM(processedHtml, {
|
|
1628
|
+
url: `file://${resolvedPath}`,
|
|
1629
|
+
});
|
|
1630
|
+
const { window } = dom;
|
|
1631
|
+
const { document } = window;
|
|
1632
|
+
|
|
1633
|
+
const findings = [];
|
|
1634
|
+
|
|
1635
|
+
// Element-level checks (borders + colors + motion)
|
|
1636
|
+
for (const el of document.querySelectorAll('*')) {
|
|
1637
|
+
const tag = el.tagName.toLowerCase();
|
|
1638
|
+
const style = window.getComputedStyle(el);
|
|
1639
|
+
for (const f of checkElementBorders(tag, style)) {
|
|
1640
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1641
|
+
}
|
|
1642
|
+
for (const f of checkElementColors(el, style, tag, window)) {
|
|
1643
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1644
|
+
}
|
|
1645
|
+
for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
|
|
1646
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1647
|
+
}
|
|
1648
|
+
for (const f of checkElementMotion(tag, style)) {
|
|
1649
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Page-level checks (only for full pages, not partials)
|
|
1654
|
+
if (isFullPage(html)) {
|
|
1655
|
+
for (const f of checkPageTypography(document, window)) {
|
|
1656
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1657
|
+
}
|
|
1658
|
+
for (const f of checkPageLayout(document, window)) {
|
|
1659
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1660
|
+
}
|
|
1661
|
+
for (const f of checkHtmlPatterns(html)) {
|
|
1662
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
window.close();
|
|
1667
|
+
return findings;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// ---------------------------------------------------------------------------
|
|
1671
|
+
// Puppeteer detection (for URLs)
|
|
1672
|
+
// ---------------------------------------------------------------------------
|
|
1673
|
+
|
|
1674
|
+
async function detectUrl(url) {
|
|
1675
|
+
let puppeteer;
|
|
1676
|
+
try {
|
|
1677
|
+
puppeteer = await import('puppeteer');
|
|
1678
|
+
} catch {
|
|
1679
|
+
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Read the browser detection script — reuse it instead of reimplementing
|
|
1683
|
+
const browserScriptPath = path.resolve(
|
|
1684
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
1685
|
+
'detect-antipatterns-browser.js'
|
|
1686
|
+
);
|
|
1687
|
+
let browserScript;
|
|
1688
|
+
try {
|
|
1689
|
+
browserScript = fs.readFileSync(browserScriptPath, 'utf-8');
|
|
1690
|
+
} catch {
|
|
1691
|
+
throw new Error(`Browser script not found at ${browserScriptPath}`);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const browser = await puppeteer.default.launch({ headless: true });
|
|
1695
|
+
const page = await browser.newPage();
|
|
1696
|
+
await page.setViewport({ width: 1280, height: 800 });
|
|
1697
|
+
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 });
|
|
1698
|
+
|
|
1699
|
+
// Inject the browser detection script and collect results
|
|
1700
|
+
await page.evaluate(browserScript);
|
|
1701
|
+
const results = await page.evaluate(() => {
|
|
1702
|
+
if (!window.impeccableScan) return [];
|
|
1703
|
+
const allFindings = window.impeccableScan();
|
|
1704
|
+
return allFindings.flatMap(({ findings }) =>
|
|
1705
|
+
findings.map(f => ({ id: f.type, snippet: f.detail }))
|
|
1706
|
+
);
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
await browser.close();
|
|
1710
|
+
return results.map(f => finding(f.id, url, f.snippet));
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// ---------------------------------------------------------------------------
|
|
1714
|
+
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
|
|
1717
|
+
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
|
|
1718
|
+
const hasBorderRadius = (line) => /border-radius/i.test(line);
|
|
1719
|
+
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
|
|
1720
|
+
|
|
1721
|
+
function isNeutralBorderColor(str) {
|
|
1722
|
+
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
|
1723
|
+
if (!m) return false;
|
|
1724
|
+
const c = m[1].toLowerCase();
|
|
1725
|
+
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
|
1726
|
+
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
|
1727
|
+
if (hex) {
|
|
1728
|
+
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
|
1729
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
|
1730
|
+
}
|
|
1731
|
+
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
|
|
1732
|
+
if (shex) {
|
|
1733
|
+
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
|
|
1734
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
|
|
1735
|
+
}
|
|
1736
|
+
return false;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const REGEX_MATCHERS = [
|
|
1740
|
+
// --- Side-tab ---
|
|
1741
|
+
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
|
1742
|
+
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
|
|
1743
|
+
fmt: (m) => m[0] },
|
|
1744
|
+
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
|
|
1745
|
+
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
|
|
1746
|
+
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
|
|
1747
|
+
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
|
|
1748
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
1749
|
+
fmt: (m) => m[0] },
|
|
1750
|
+
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
|
|
1751
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
1752
|
+
fmt: (m) => m[0] },
|
|
1753
|
+
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
|
|
1754
|
+
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
|
1755
|
+
fmt: (m) => m[0] },
|
|
1756
|
+
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
|
|
1757
|
+
test: (m) => +m[1] >= 3,
|
|
1758
|
+
fmt: (m) => m[0] },
|
|
1759
|
+
// --- Border accent on rounded ---
|
|
1760
|
+
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
|
|
1761
|
+
test: (m, line) => hasRounded(line) && +m[1] >= 1,
|
|
1762
|
+
fmt: (m) => m[0] },
|
|
1763
|
+
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
|
|
1764
|
+
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
|
|
1765
|
+
fmt: (m) => m[0] },
|
|
1766
|
+
// --- Overused font ---
|
|
1767
|
+
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica)\b/gi,
|
|
1768
|
+
test: () => true,
|
|
1769
|
+
fmt: (m) => m[0] },
|
|
1770
|
+
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
|
|
1771
|
+
test: () => true,
|
|
1772
|
+
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
|
1773
|
+
// --- Pure black background ---
|
|
1774
|
+
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
|
|
1775
|
+
test: () => true,
|
|
1776
|
+
fmt: (m) => m[0] },
|
|
1777
|
+
// --- Gradient text ---
|
|
1778
|
+
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
|
1779
|
+
test: (m, line) => /gradient/i.test(line),
|
|
1780
|
+
fmt: () => 'background-clip: text + gradient' },
|
|
1781
|
+
// --- Gradient text (Tailwind) ---
|
|
1782
|
+
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
|
1783
|
+
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
|
1784
|
+
fmt: () => 'bg-clip-text + bg-gradient' },
|
|
1785
|
+
// --- Tailwind pure black background ---
|
|
1786
|
+
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
|
|
1787
|
+
test: () => true,
|
|
1788
|
+
fmt: (m) => m[0] },
|
|
1789
|
+
// --- Tailwind gray on colored bg ---
|
|
1790
|
+
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
|
|
1791
|
+
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
|
1792
|
+
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
|
|
1793
|
+
// --- Tailwind AI palette ---
|
|
1794
|
+
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
|
|
1795
|
+
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
|
|
1796
|
+
fmt: (m) => `${m[0]} on heading` },
|
|
1797
|
+
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
|
|
1798
|
+
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
|
|
1799
|
+
fmt: (m) => `${m[0]} gradient` },
|
|
1800
|
+
// --- Bounce/elastic easing ---
|
|
1801
|
+
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
|
|
1802
|
+
test: () => true,
|
|
1803
|
+
fmt: () => 'animate-bounce (Tailwind)' },
|
|
1804
|
+
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
|
|
1805
|
+
test: () => true,
|
|
1806
|
+
fmt: (m) => m[0] },
|
|
1807
|
+
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
|
|
1808
|
+
test: (m) => {
|
|
1809
|
+
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
|
1810
|
+
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
|
|
1811
|
+
},
|
|
1812
|
+
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
|
|
1813
|
+
// --- Layout property transition ---
|
|
1814
|
+
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
|
|
1815
|
+
test: (m) => {
|
|
1816
|
+
const val = m[1].toLowerCase();
|
|
1817
|
+
if (/\ball\b/.test(val)) return false;
|
|
1818
|
+
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
|
1819
|
+
},
|
|
1820
|
+
fmt: (m) => {
|
|
1821
|
+
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
1822
|
+
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
|
|
1823
|
+
} },
|
|
1824
|
+
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
|
|
1825
|
+
test: (m) => {
|
|
1826
|
+
const val = m[1].toLowerCase();
|
|
1827
|
+
if (/\ball\b/.test(val)) return false;
|
|
1828
|
+
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
|
|
1829
|
+
},
|
|
1830
|
+
fmt: (m) => {
|
|
1831
|
+
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
1832
|
+
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
|
|
1833
|
+
} },
|
|
1834
|
+
];
|
|
1835
|
+
|
|
1836
|
+
const REGEX_ANALYZERS = [
|
|
1837
|
+
// Single font
|
|
1838
|
+
(content, filePath) => {
|
|
1839
|
+
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
1840
|
+
const fonts = new Set();
|
|
1841
|
+
let m;
|
|
1842
|
+
while ((m = fontFamilyRe.exec(content)) !== null) {
|
|
1843
|
+
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
|
1844
|
+
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
|
1848
|
+
while ((m = gfRe.exec(content)) !== null) {
|
|
1849
|
+
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
|
|
1850
|
+
}
|
|
1851
|
+
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
|
1852
|
+
const name = [...fonts][0];
|
|
1853
|
+
const lines = content.split('\n');
|
|
1854
|
+
let line = 1;
|
|
1855
|
+
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
|
|
1856
|
+
return [finding('single-font', filePath, `only font used is ${name}`, line)];
|
|
1857
|
+
},
|
|
1858
|
+
// Flat type hierarchy
|
|
1859
|
+
(content, filePath) => {
|
|
1860
|
+
const sizes = new Set();
|
|
1861
|
+
const REM = 16;
|
|
1862
|
+
let m;
|
|
1863
|
+
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
|
|
1864
|
+
while ((m = sizeRe.exec(content)) !== null) {
|
|
1865
|
+
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
|
|
1866
|
+
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
|
|
1867
|
+
}
|
|
1868
|
+
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
|
|
1869
|
+
while ((m = clampRe.exec(content)) !== null) {
|
|
1870
|
+
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
|
|
1871
|
+
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
|
|
1872
|
+
}
|
|
1873
|
+
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
|
|
1874
|
+
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
|
|
1875
|
+
if (sizes.size < 3) return [];
|
|
1876
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
1877
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
1878
|
+
if (ratio >= 2.0) return [];
|
|
1879
|
+
const lines = content.split('\n');
|
|
1880
|
+
let line = 1;
|
|
1881
|
+
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
|
1882
|
+
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
|
1883
|
+
},
|
|
1884
|
+
// Monotonous spacing (regex)
|
|
1885
|
+
(content, filePath) => {
|
|
1886
|
+
const vals = [];
|
|
1887
|
+
let m;
|
|
1888
|
+
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
|
1889
|
+
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
|
1890
|
+
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
|
1891
|
+
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
|
1892
|
+
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
|
1893
|
+
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
|
1894
|
+
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
|
1895
|
+
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
|
1896
|
+
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
|
1897
|
+
if (rounded.length < 10) return [];
|
|
1898
|
+
const counts = {};
|
|
1899
|
+
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
|
1900
|
+
const maxCount = Math.max(...Object.values(counts));
|
|
1901
|
+
const pct = maxCount / rounded.length;
|
|
1902
|
+
const unique = [...new Set(rounded)].filter(v => v > 0);
|
|
1903
|
+
if (pct <= 0.6 || unique.length > 3) return [];
|
|
1904
|
+
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
1905
|
+
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
|
1906
|
+
},
|
|
1907
|
+
// Everything centered (regex)
|
|
1908
|
+
(content, filePath) => {
|
|
1909
|
+
const lines = content.split('\n');
|
|
1910
|
+
let centered = 0, total = 0;
|
|
1911
|
+
for (const line of lines) {
|
|
1912
|
+
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
|
|
1913
|
+
total++;
|
|
1914
|
+
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (total < 5 || centered / total <= 0.7) return [];
|
|
1918
|
+
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
|
|
1919
|
+
},
|
|
1920
|
+
// Dark glow (page-level: dark bg + colored box-shadow with blur)
|
|
1921
|
+
(content, filePath) => {
|
|
1922
|
+
// Check if page has a dark background
|
|
1923
|
+
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
|
|
1924
|
+
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
|
|
1925
|
+
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
|
|
1926
|
+
if (!hasDarkBg) return [];
|
|
1927
|
+
|
|
1928
|
+
// Check for colored box-shadow with blur > 4px
|
|
1929
|
+
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
|
1930
|
+
let m;
|
|
1931
|
+
while ((m = shadowRe.exec(content)) !== null) {
|
|
1932
|
+
const val = m[1];
|
|
1933
|
+
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
1934
|
+
if (!colorMatch) continue;
|
|
1935
|
+
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
|
1936
|
+
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
|
|
1937
|
+
// Check blur: look for pattern like "0 0 20px" (third number > 4)
|
|
1938
|
+
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
|
1939
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
1940
|
+
const lines = content.substring(0, m.index).split('\n');
|
|
1941
|
+
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return [];
|
|
1945
|
+
},
|
|
1946
|
+
];
|
|
1947
|
+
|
|
1948
|
+
// ---------------------------------------------------------------------------
|
|
1949
|
+
// Style block extraction (Vue/Svelte <style> blocks)
|
|
1950
|
+
// ---------------------------------------------------------------------------
|
|
1951
|
+
|
|
1952
|
+
function extractStyleBlocks(content, ext) {
|
|
1953
|
+
ext = ext.toLowerCase();
|
|
1954
|
+
if (ext !== '.vue' && ext !== '.svelte') return [];
|
|
1955
|
+
const blocks = [];
|
|
1956
|
+
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
1957
|
+
let m;
|
|
1958
|
+
while ((m = re.exec(content)) !== null) {
|
|
1959
|
+
const before = content.substring(0, m.index);
|
|
1960
|
+
const startLine = before.split('\n').length + 1;
|
|
1961
|
+
blocks.push({ content: m[1], startLine });
|
|
1962
|
+
}
|
|
1963
|
+
return blocks;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// ---------------------------------------------------------------------------
|
|
1967
|
+
// CSS-in-JS extraction (styled-components, emotion)
|
|
1968
|
+
// ---------------------------------------------------------------------------
|
|
1969
|
+
|
|
1970
|
+
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
|
|
1971
|
+
|
|
1972
|
+
function extractCSSinJS(content, ext) {
|
|
1973
|
+
ext = ext.toLowerCase();
|
|
1974
|
+
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
|
|
1975
|
+
const blocks = [];
|
|
1976
|
+
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
|
|
1977
|
+
let m;
|
|
1978
|
+
while ((m = re.exec(content)) !== null) {
|
|
1979
|
+
const before = content.substring(0, m.index);
|
|
1980
|
+
const startLine = before.split('\n').length;
|
|
1981
|
+
blocks.push({ content: m[1], startLine });
|
|
1982
|
+
}
|
|
1983
|
+
return blocks;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null) {
|
|
1987
|
+
const findings = [];
|
|
1988
|
+
for (const matcher of REGEX_MATCHERS) {
|
|
1989
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1990
|
+
const line = lines[i];
|
|
1991
|
+
matcher.regex.lastIndex = 0;
|
|
1992
|
+
let m;
|
|
1993
|
+
while ((m = matcher.regex.exec(line)) !== null) {
|
|
1994
|
+
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
|
|
1995
|
+
const context = blockContext
|
|
1996
|
+
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
|
|
1997
|
+
: line;
|
|
1998
|
+
if (matcher.test(m, context)) {
|
|
1999
|
+
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
return findings;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function detectText(content, filePath) {
|
|
2008
|
+
const findings = [];
|
|
2009
|
+
const lines = content.split('\n');
|
|
2010
|
+
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
|
2011
|
+
|
|
2012
|
+
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
|
2013
|
+
// Enable block context for CSS files where related properties span multiple lines
|
|
2014
|
+
const cssLike = new Set(['.css', '.scss', '.less']);
|
|
2015
|
+
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null));
|
|
2016
|
+
|
|
2017
|
+
// Extract and scan <style> blocks from Vue/Svelte SFCs
|
|
2018
|
+
const styleBlocks = extractStyleBlocks(content, ext);
|
|
2019
|
+
for (const block of styleBlocks) {
|
|
2020
|
+
const blockLines = block.content.split('\n');
|
|
2021
|
+
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// Extract and scan CSS-in-JS template literals
|
|
2025
|
+
const cssJsBlocks = extractCSSinJS(content, ext);
|
|
2026
|
+
for (const block of cssJsBlocks) {
|
|
2027
|
+
const blockLines = block.content.split('\n');
|
|
2028
|
+
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true));
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
|
|
2032
|
+
const deduped = [];
|
|
2033
|
+
for (const f of findings) {
|
|
2034
|
+
const isDupe = deduped.some(d =>
|
|
2035
|
+
d.antipattern === f.antipattern &&
|
|
2036
|
+
d.snippet === f.snippet &&
|
|
2037
|
+
Math.abs(d.line - f.line) <= 2
|
|
2038
|
+
);
|
|
2039
|
+
if (!isDupe) deduped.push(f);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Page-level analyzers only run on full pages
|
|
2043
|
+
if (isFullPage(content)) {
|
|
2044
|
+
for (const analyzer of REGEX_ANALYZERS) {
|
|
2045
|
+
deduped.push(...analyzer(content, filePath));
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
return deduped;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// ---------------------------------------------------------------------------
|
|
2053
|
+
// File walker
|
|
2054
|
+
// ---------------------------------------------------------------------------
|
|
2055
|
+
|
|
2056
|
+
const SKIP_DIRS = new Set([
|
|
2057
|
+
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
|
|
2058
|
+
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
|
|
2059
|
+
]);
|
|
2060
|
+
|
|
2061
|
+
const SCANNABLE_EXTENSIONS = new Set([
|
|
2062
|
+
'.html', '.htm', '.css', '.scss', '.less',
|
|
2063
|
+
'.jsx', '.tsx', '.js', '.ts',
|
|
2064
|
+
'.vue', '.svelte', '.astro',
|
|
2065
|
+
]);
|
|
2066
|
+
|
|
2067
|
+
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
2068
|
+
|
|
2069
|
+
function walkDir(dir) {
|
|
2070
|
+
const files = [];
|
|
2071
|
+
let entries;
|
|
2072
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
|
2073
|
+
for (const entry of entries) {
|
|
2074
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
2075
|
+
const full = path.join(dir, entry.name);
|
|
2076
|
+
if (entry.isDirectory()) files.push(...walkDir(full));
|
|
2077
|
+
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
|
|
2078
|
+
}
|
|
2079
|
+
return files;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// ---------------------------------------------------------------------------
|
|
2083
|
+
// Output formatting
|
|
2084
|
+
// ---------------------------------------------------------------------------
|
|
2085
|
+
|
|
2086
|
+
function formatFindings(findings, jsonMode) {
|
|
2087
|
+
if (jsonMode) return JSON.stringify(findings, null, 2);
|
|
2088
|
+
|
|
2089
|
+
const grouped = {};
|
|
2090
|
+
for (const f of findings) {
|
|
2091
|
+
if (!grouped[f.file]) grouped[f.file] = [];
|
|
2092
|
+
grouped[f.file].push(f);
|
|
2093
|
+
}
|
|
2094
|
+
const out = [];
|
|
2095
|
+
for (const [file, items] of Object.entries(grouped)) {
|
|
2096
|
+
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
|
|
2097
|
+
out.push(`\n${file}${importNote}`);
|
|
2098
|
+
for (const item of items) {
|
|
2099
|
+
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
|
|
2100
|
+
out.push(` → ${item.description}`);
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
|
2104
|
+
return out.join('\n');
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
// ---------------------------------------------------------------------------
|
|
2108
|
+
// Stdin handling
|
|
2109
|
+
// ---------------------------------------------------------------------------
|
|
2110
|
+
|
|
2111
|
+
async function handleStdin() {
|
|
2112
|
+
const chunks = [];
|
|
2113
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2114
|
+
const input = Buffer.concat(chunks).toString('utf-8');
|
|
2115
|
+
try {
|
|
2116
|
+
const parsed = JSON.parse(input);
|
|
2117
|
+
const fp = parsed?.tool_input?.file_path;
|
|
2118
|
+
if (fp && fs.existsSync(fp)) {
|
|
2119
|
+
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
|
|
2120
|
+
? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp);
|
|
2121
|
+
}
|
|
2122
|
+
} catch { /* not JSON */ }
|
|
2123
|
+
return detectText(input, '<stdin>');
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// ---------------------------------------------------------------------------
|
|
2127
|
+
// Import graph (multi-file awareness)
|
|
2128
|
+
// ---------------------------------------------------------------------------
|
|
2129
|
+
|
|
2130
|
+
function resolveImport(specifier, fromDir, fileSet) {
|
|
2131
|
+
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
|
|
2132
|
+
const base = path.resolve(fromDir, specifier);
|
|
2133
|
+
if (fileSet.has(base)) return base;
|
|
2134
|
+
for (const ext of SCANNABLE_EXTENSIONS) {
|
|
2135
|
+
const withExt = base + ext;
|
|
2136
|
+
if (fileSet.has(withExt)) return withExt;
|
|
2137
|
+
}
|
|
2138
|
+
// index file convention
|
|
2139
|
+
for (const ext of SCANNABLE_EXTENSIONS) {
|
|
2140
|
+
const indexFile = path.join(base, 'index' + ext);
|
|
2141
|
+
if (fileSet.has(indexFile)) return indexFile;
|
|
2142
|
+
}
|
|
2143
|
+
return null;
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function buildImportGraph(files) {
|
|
2147
|
+
const fileSet = new Set(files);
|
|
2148
|
+
const graph = new Map();
|
|
2149
|
+
|
|
2150
|
+
for (const file of files) {
|
|
2151
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
2152
|
+
const dir = path.dirname(file);
|
|
2153
|
+
const imports = new Set();
|
|
2154
|
+
|
|
2155
|
+
// ES imports: import ... from '...' and import '...'
|
|
2156
|
+
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
|
|
2157
|
+
let m;
|
|
2158
|
+
while ((m = esRe.exec(content)) !== null) {
|
|
2159
|
+
const resolved = resolveImport(m[1], dir, fileSet);
|
|
2160
|
+
if (resolved) imports.add(resolved);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// CSS @import
|
|
2164
|
+
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
|
|
2165
|
+
while ((m = cssRe.exec(content)) !== null) {
|
|
2166
|
+
const resolved = resolveImport(m[1], dir, fileSet);
|
|
2167
|
+
if (resolved) imports.add(resolved);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// SCSS @use / @forward
|
|
2171
|
+
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
|
|
2172
|
+
while ((m = scssRe.exec(content)) !== null) {
|
|
2173
|
+
const resolved = resolveImport(m[1], dir, fileSet);
|
|
2174
|
+
if (resolved) imports.add(resolved);
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
graph.set(file, imports);
|
|
2178
|
+
}
|
|
2179
|
+
return graph;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// ---------------------------------------------------------------------------
|
|
2183
|
+
// Framework dev server detection
|
|
2184
|
+
// ---------------------------------------------------------------------------
|
|
2185
|
+
|
|
2186
|
+
const FRAMEWORK_CONFIGS = [
|
|
2187
|
+
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
|
|
2188
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2189
|
+
fingerprint: { header: 'x-powered-by', value: /next/i } },
|
|
2190
|
+
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
|
|
2191
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2192
|
+
fingerprint: { header: 'x-sveltekit-page', value: null } },
|
|
2193
|
+
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
|
|
2194
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2195
|
+
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
|
|
2196
|
+
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
|
|
2197
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2198
|
+
fingerprint: { body: /@vite\/client/ } },
|
|
2199
|
+
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
|
|
2200
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2201
|
+
fingerprint: { body: /astro/i } },
|
|
2202
|
+
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
|
|
2203
|
+
portRe: /"port"\s*:\s*(\d+)/,
|
|
2204
|
+
fingerprint: { body: /ng-version/i } },
|
|
2205
|
+
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
|
|
2206
|
+
portRe: /port\s*[:=]\s*(\d+)/,
|
|
2207
|
+
fingerprint: { header: 'x-powered-by', value: /remix/i } },
|
|
2208
|
+
];
|
|
2209
|
+
|
|
2210
|
+
function detectFrameworkConfig(dir) {
|
|
2211
|
+
let entries;
|
|
2212
|
+
try { entries = fs.readdirSync(dir); } catch { return null; }
|
|
2213
|
+
const entrySet = new Set(entries);
|
|
2214
|
+
|
|
2215
|
+
for (const cfg of FRAMEWORK_CONFIGS) {
|
|
2216
|
+
const match = cfg.files.find(f => entrySet.has(f));
|
|
2217
|
+
if (!match) continue;
|
|
2218
|
+
|
|
2219
|
+
const configPath = path.join(dir, match);
|
|
2220
|
+
let port = cfg.defaultPort;
|
|
2221
|
+
try {
|
|
2222
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
2223
|
+
const portMatch = content.match(cfg.portRe);
|
|
2224
|
+
if (portMatch) port = parseInt(portMatch[1], 10);
|
|
2225
|
+
} catch { /* use default */ }
|
|
2226
|
+
|
|
2227
|
+
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
|
|
2228
|
+
}
|
|
2229
|
+
return null;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Check if a port is listening and optionally verify it matches the expected framework.
|
|
2234
|
+
* Returns { listening: true, matched: true/false } or { listening: false }.
|
|
2235
|
+
*/
|
|
2236
|
+
async function isPortListening(port, fingerprint = null) {
|
|
2237
|
+
if (!fingerprint) {
|
|
2238
|
+
// Simple TCP probe fallback
|
|
2239
|
+
const net = await import('node:net');
|
|
2240
|
+
return new Promise((resolve) => {
|
|
2241
|
+
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
|
|
2242
|
+
sock.setTimeout(500);
|
|
2243
|
+
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
|
|
2244
|
+
sock.on('error', () => resolve({ listening: false }));
|
|
2245
|
+
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// HTTP probe with fingerprint matching
|
|
2250
|
+
try {
|
|
2251
|
+
const controller = new AbortController();
|
|
2252
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
2253
|
+
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
|
|
2254
|
+
clearTimeout(timeout);
|
|
2255
|
+
|
|
2256
|
+
// Check header fingerprint
|
|
2257
|
+
if (fingerprint.header) {
|
|
2258
|
+
const val = res.headers.get(fingerprint.header);
|
|
2259
|
+
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
|
|
2260
|
+
return { listening: true, matched: true };
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Check body fingerprint
|
|
2265
|
+
if (fingerprint.body) {
|
|
2266
|
+
const body = await res.text();
|
|
2267
|
+
if (fingerprint.body.test(body)) {
|
|
2268
|
+
return { listening: true, matched: true };
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// Port is listening but doesn't match the expected framework
|
|
2273
|
+
return { listening: true, matched: false };
|
|
2274
|
+
} catch {
|
|
2275
|
+
return { listening: false };
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// ---------------------------------------------------------------------------
|
|
2280
|
+
// CLI
|
|
2281
|
+
// ---------------------------------------------------------------------------
|
|
2282
|
+
|
|
2283
|
+
async function confirm(question) {
|
|
2284
|
+
const rl = (await import('node:readline')).default.createInterface({
|
|
2285
|
+
input: process.stdin, output: process.stderr,
|
|
2286
|
+
});
|
|
2287
|
+
return new Promise((resolve) => {
|
|
2288
|
+
rl.question(`${question} [Y/n] `, (answer) => {
|
|
2289
|
+
rl.close();
|
|
2290
|
+
resolve(!answer || /^y(es)?$/i.test(answer.trim()));
|
|
2291
|
+
});
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
function printUsage() {
|
|
2296
|
+
console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
|
|
2297
|
+
|
|
2298
|
+
Scan files or URLs for UI anti-patterns and design quality issues.
|
|
2299
|
+
|
|
2300
|
+
Options:
|
|
2301
|
+
--fast Regex-only mode (skip jsdom, faster but misses linked stylesheets)
|
|
2302
|
+
--json Output results as JSON
|
|
2303
|
+
--help Show this help message
|
|
2304
|
+
|
|
2305
|
+
Detection modes:
|
|
2306
|
+
HTML files jsdom with computed styles (default, catches linked CSS)
|
|
2307
|
+
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
|
2308
|
+
URLs Puppeteer full browser rendering (auto-detected)
|
|
2309
|
+
--fast Forces regex for all files
|
|
2310
|
+
|
|
2311
|
+
Examples:
|
|
2312
|
+
impeccable detect src/
|
|
2313
|
+
impeccable detect index.html
|
|
2314
|
+
impeccable detect https://example.com
|
|
2315
|
+
impeccable detect --fast --json .`);
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
async function main() {
|
|
2319
|
+
const args = process.argv.slice(2);
|
|
2320
|
+
const jsonMode = args.includes('--json');
|
|
2321
|
+
const helpMode = args.includes('--help');
|
|
2322
|
+
const fastMode = args.includes('--fast');
|
|
2323
|
+
const targets = args.filter(a => !a.startsWith('--'));
|
|
2324
|
+
|
|
2325
|
+
if (helpMode) { printUsage(); process.exit(0); }
|
|
2326
|
+
|
|
2327
|
+
let allFindings = [];
|
|
2328
|
+
|
|
2329
|
+
if (!process.stdin.isTTY && targets.length === 0) {
|
|
2330
|
+
allFindings = await handleStdin();
|
|
2331
|
+
} else {
|
|
2332
|
+
const paths = targets.length > 0 ? targets : [process.cwd()];
|
|
2333
|
+
|
|
2334
|
+
for (const target of paths) {
|
|
2335
|
+
if (/^https?:\/\//i.test(target)) {
|
|
2336
|
+
try { allFindings.push(...await detectUrl(target)); }
|
|
2337
|
+
catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const resolved = path.resolve(target);
|
|
2342
|
+
let stat;
|
|
2343
|
+
try { stat = fs.statSync(resolved); }
|
|
2344
|
+
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
|
2345
|
+
|
|
2346
|
+
if (stat.isDirectory()) {
|
|
2347
|
+
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
|
2348
|
+
if (!jsonMode) {
|
|
2349
|
+
const fwConfig = detectFrameworkConfig(resolved);
|
|
2350
|
+
if (fwConfig) {
|
|
2351
|
+
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
|
2352
|
+
if (probe.listening && probe.matched) {
|
|
2353
|
+
process.stderr.write(
|
|
2354
|
+
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
|
|
2355
|
+
`For more accurate results, scan the running site:\n` +
|
|
2356
|
+
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
|
2357
|
+
);
|
|
2358
|
+
} else if (probe.listening && !probe.matched) {
|
|
2359
|
+
process.stderr.write(
|
|
2360
|
+
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
|
2361
|
+
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
|
|
2362
|
+
);
|
|
2363
|
+
} else {
|
|
2364
|
+
process.stderr.write(
|
|
2365
|
+
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
|
|
2366
|
+
`Start the dev server and scan via URL for best results:\n` +
|
|
2367
|
+
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const files = walkDir(resolved);
|
|
2374
|
+
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
|
2375
|
+
|
|
2376
|
+
// Warn and confirm if scanning many files (jsdom is slow per HTML file)
|
|
2377
|
+
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
|
|
2378
|
+
process.stderr.write(
|
|
2379
|
+
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
|
2380
|
+
`Scanning may take a while${htmlCount > 10 ? ' (jsdom processes each HTML file individually)' : ''}.\n` +
|
|
2381
|
+
`Use --fast to skip jsdom, or target a specific subdirectory.\n`
|
|
2382
|
+
);
|
|
2383
|
+
const ok = await confirm('Continue?');
|
|
2384
|
+
if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// Build import graph for multi-file awareness
|
|
2388
|
+
const graph = buildImportGraph(files);
|
|
2389
|
+
// Build reverse map: file -> set of files that import it
|
|
2390
|
+
const importedByMap = new Map();
|
|
2391
|
+
for (const [importer, imports] of graph) {
|
|
2392
|
+
for (const imported of imports) {
|
|
2393
|
+
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
|
|
2394
|
+
importedByMap.get(imported).add(importer);
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
for (const file of files) {
|
|
2399
|
+
const ext = path.extname(file).toLowerCase();
|
|
2400
|
+
let fileFindings;
|
|
2401
|
+
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
|
2402
|
+
fileFindings = await detectHtml(file);
|
|
2403
|
+
} else {
|
|
2404
|
+
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file);
|
|
2405
|
+
}
|
|
2406
|
+
// Annotate findings with import context
|
|
2407
|
+
const importers = importedByMap.get(file);
|
|
2408
|
+
if (importers && importers.size > 0) {
|
|
2409
|
+
const importerNames = [...importers].map(f => path.basename(f));
|
|
2410
|
+
for (const f of fileFindings) {
|
|
2411
|
+
f.importedBy = importerNames;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
allFindings.push(...fileFindings);
|
|
2415
|
+
}
|
|
2416
|
+
} else if (stat.isFile()) {
|
|
2417
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
2418
|
+
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
|
|
2419
|
+
allFindings.push(...await detectHtml(resolved));
|
|
2420
|
+
} else {
|
|
2421
|
+
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved));
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
if (allFindings.length > 0) {
|
|
2428
|
+
process.stderr.write(formatFindings(allFindings, jsonMode) + '\n');
|
|
2429
|
+
process.exit(2);
|
|
2430
|
+
}
|
|
2431
|
+
if (jsonMode) process.stdout.write('[]\n');
|
|
2432
|
+
process.exit(0);
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// ---------------------------------------------------------------------------
|
|
2436
|
+
// Live detection server
|
|
2437
|
+
// ---------------------------------------------------------------------------
|
|
2438
|
+
|
|
2439
|
+
async function findOpenPort(start = 8400) {
|
|
2440
|
+
const net = await import('node:net');
|
|
2441
|
+
return new Promise((resolve) => {
|
|
2442
|
+
const server = net.default.createServer();
|
|
2443
|
+
server.listen(start, '127.0.0.1', () => {
|
|
2444
|
+
const port = server.address().port;
|
|
2445
|
+
server.close(() => resolve(port));
|
|
2446
|
+
});
|
|
2447
|
+
server.on('error', () => resolve(findOpenPort(start + 1)));
|
|
2448
|
+
});
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
async function liveCli() {
|
|
2452
|
+
const args = process.argv.slice(2);
|
|
2453
|
+
const helpMode = args.includes('--help');
|
|
2454
|
+
const portArg = args.find(a => a.startsWith('--port='));
|
|
2455
|
+
const requestedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
|
|
2456
|
+
|
|
2457
|
+
if (helpMode) {
|
|
2458
|
+
console.log(`Usage: impeccable-detect live [options]
|
|
2459
|
+
|
|
2460
|
+
Start a local server that serves the browser detection overlay script.
|
|
2461
|
+
Inject the script into any page to scan for anti-patterns in real time.
|
|
2462
|
+
|
|
2463
|
+
Options:
|
|
2464
|
+
--port=PORT Use a specific port (default: auto-detect unused port)
|
|
2465
|
+
--help Show this help message
|
|
2466
|
+
|
|
2467
|
+
The server provides:
|
|
2468
|
+
/detect.js The detection overlay script (inject via <script> tag)
|
|
2469
|
+
/scan Trigger a scan and return JSON results`);
|
|
2470
|
+
process.exit(0);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
const http = await import('node:http');
|
|
2474
|
+
const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'detect-antipatterns-browser.js');
|
|
2475
|
+
|
|
2476
|
+
let browserScript;
|
|
2477
|
+
try {
|
|
2478
|
+
browserScript = fs.readFileSync(scriptPath, 'utf-8');
|
|
2479
|
+
} catch {
|
|
2480
|
+
process.stderr.write('Error: Browser script not found. Run `npm run build:browser` first.\n');
|
|
2481
|
+
process.exit(1);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
const port = requestedPort || await findOpenPort();
|
|
2485
|
+
|
|
2486
|
+
const server = http.default.createServer((req, res) => {
|
|
2487
|
+
// CORS headers for cross-origin injection
|
|
2488
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2489
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
2490
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
|
2491
|
+
|
|
2492
|
+
if (req.url === '/detect.js' || req.url === '/') {
|
|
2493
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
2494
|
+
res.end(browserScript);
|
|
2495
|
+
} else if (req.url === '/health') {
|
|
2496
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2497
|
+
res.end(JSON.stringify({ status: 'ok', version: '2.0.2' }));
|
|
2498
|
+
} else {
|
|
2499
|
+
res.writeHead(404);
|
|
2500
|
+
res.end('Not found');
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
server.listen(port, '127.0.0.1', () => {
|
|
2505
|
+
const url = `http://localhost:${port}`;
|
|
2506
|
+
console.log(`Impeccable live detection server running on ${url}\n`);
|
|
2507
|
+
console.log(`Inject into any page:`);
|
|
2508
|
+
console.log(` const s = document.createElement('script');`);
|
|
2509
|
+
console.log(` s.src = '${url}/detect.js';`);
|
|
2510
|
+
console.log(` document.head.appendChild(s);\n`);
|
|
2511
|
+
console.log(`Or add to HTML:`);
|
|
2512
|
+
console.log(` <script src="${url}/detect.js"><\/script>\n`);
|
|
2513
|
+
console.log(`Press Ctrl+C to stop.`);
|
|
2514
|
+
});
|
|
2515
|
+
|
|
2516
|
+
// Graceful shutdown
|
|
2517
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
2518
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
// ---------------------------------------------------------------------------
|
|
2522
|
+
// Entry point
|
|
2523
|
+
// ---------------------------------------------------------------------------
|
|
2524
|
+
|
|
2525
|
+
if (!IS_BROWSER) {
|
|
2526
|
+
const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
|
|
2527
|
+
process.argv[1]?.endsWith('detect-antipatterns.mjs/');
|
|
2528
|
+
if (isMainModule) main();
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// @browser-strip-end
|
|
2532
|
+
|
|
2533
|
+
// ─── Section 9: Exports ─────────────────────────────────────────────────────
|
|
2534
|
+
// @browser-strip-start
|
|
2535
|
+
|
|
2536
|
+
export {
|
|
2537
|
+
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
|
|
2538
|
+
checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
|
2539
|
+
detectHtml, detectUrl, detectText,
|
|
2540
|
+
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
|
2541
|
+
extractStyleBlocks, extractCSSinJS,
|
|
2542
|
+
buildImportGraph, resolveImport,
|
|
2543
|
+
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
|
|
2544
|
+
main as detectCli,
|
|
2545
|
+
liveCli,
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
// @browser-strip-end
|