@monoes/monodesign 1.2.17 → 1.2.20
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/README.md +1 -1
- package/cli/engine/detect-antipatterns-browser.js +12 -1
- package/cli/engine/engines/browser/drivers.mjs +9 -19
- package/cli/engine/registry/antipatterns-provider.mjs +60 -0
- package/cli/engine/registry/antipatterns-quality.mjs +218 -0
- package/cli/engine/registry/antipatterns-slop.mjs +226 -0
- package/cli/engine/registry/antipatterns.mjs +7 -494
- package/cli/engine/rules/checks-background.mjs +155 -0
- package/cli/engine/rules/checks-css-values.mjs +137 -0
- package/cli/engine/rules/checks-dom-effects.mjs +243 -0
- package/cli/engine/rules/checks-dom.mjs +148 -0
- package/cli/engine/rules/checks-element.mjs +286 -0
- package/cli/engine/rules/checks-html.mjs +204 -0
- package/cli/engine/rules/checks-overflow.mjs +263 -0
- package/cli/engine/rules/checks-page.mjs +465 -0
- package/cli/engine/rules/checks-pure.mjs +453 -0
- package/cli/engine/rules/checks-quality.mjs +447 -0
- package/cli/engine/rules/checks-stylesheet.mjs +324 -0
- package/cli/engine/rules/checks.mjs +98 -2939
- package/package.json +2 -2
- package/skill/scripts/hook-before-edit-shell.mjs +184 -0
- package/skill/scripts/hook-before-edit.mjs +9 -165
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GENERIC_FONTS,
|
|
3
|
+
OVERUSED_FONTS,
|
|
4
|
+
SAFE_TAGS,
|
|
5
|
+
isBrandFontOnOwnDomain,
|
|
6
|
+
} from '../shared/constants.mjs';
|
|
7
|
+
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
|
8
|
+
import { isCardLikeFromProps } from './checks-pure.mjs';
|
|
9
|
+
import { readOwnBackgroundColor, resolveBorderRadiusPx } from './checks-background.mjs';
|
|
10
|
+
import { parseAnyColor } from './checks-css-values.mjs';
|
|
11
|
+
import { resolveFontSizePx, cssColorIsTransparent } from './checks-quality.mjs';
|
|
12
|
+
|
|
13
|
+
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
// Browser page-level checks — use document/getComputedStyle globals
|
|
16
|
+
|
|
17
|
+
function checkTypography() {
|
|
18
|
+
const findings = [];
|
|
19
|
+
|
|
20
|
+
// Walk actual text-bearing elements and tally font usage by *computed style*.
|
|
21
|
+
// This is much more accurate than scanning CSS rules — it ignores rules that
|
|
22
|
+
// exist in the stylesheet but apply to nothing (e.g. demo classes showing
|
|
23
|
+
// anti-patterns), and counts what the user actually sees.
|
|
24
|
+
const fontUsage = new Map(); // primary font name → count of elements
|
|
25
|
+
let totalTextElements = 0;
|
|
26
|
+
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span')) {
|
|
27
|
+
// Skip monodesign's own elements
|
|
28
|
+
if (el.closest?.('.monodesign-overlay, .monodesign-label, .monodesign-banner, .monodesign-tooltip')) continue;
|
|
29
|
+
// Only count elements that actually have visible direct text
|
|
30
|
+
const hasText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
|
31
|
+
if (!hasText) continue;
|
|
32
|
+
const style = getComputedStyle(el);
|
|
33
|
+
const ff = style.fontFamily;
|
|
34
|
+
if (!ff) continue;
|
|
35
|
+
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
36
|
+
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
37
|
+
if (!primary) continue;
|
|
38
|
+
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
|
39
|
+
totalTextElements++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (totalTextElements >= 20) {
|
|
43
|
+
// A font is "primary" if it's used by at least 15% of text elements
|
|
44
|
+
const PRIMARY_THRESHOLD = 0.15;
|
|
45
|
+
for (const [font, count] of fontUsage) {
|
|
46
|
+
const share = count / totalTextElements;
|
|
47
|
+
if (share < PRIMARY_THRESHOLD) continue;
|
|
48
|
+
if (!OVERUSED_FONTS.has(font)) continue;
|
|
49
|
+
if (isBrandFontOnOwnDomain(font)) continue;
|
|
50
|
+
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Single-font check: only one distinct primary font across all text
|
|
54
|
+
if (fontUsage.size === 1) {
|
|
55
|
+
const only = [...fontUsage.keys()][0];
|
|
56
|
+
findings.push({ type: 'single-font', detail: `only font used is ${only}` });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sizes = new Set();
|
|
61
|
+
for (const el of document.querySelectorAll('h1,h2,h3,h4,h5,h6,p,span,a,li,td,th,label,button,div')) {
|
|
62
|
+
const fs = parseFloat(getComputedStyle(el).fontSize);
|
|
63
|
+
if (fs > 0 && fs < 200) sizes.add(Math.round(fs * 10) / 10);
|
|
64
|
+
}
|
|
65
|
+
if (sizes.size >= 3) {
|
|
66
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
67
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
68
|
+
if (ratio < 2.0) {
|
|
69
|
+
findings.push({ type: 'flat-type-hierarchy', detail: `Sizes: ${sorted.map(s => `${s}px`).join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return findings;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isCardLikeDOM(el) {
|
|
77
|
+
const tag = el.tagName.toLowerCase();
|
|
78
|
+
if (SAFE_TAGS.has(tag) || ['input','select','textarea','img','video','canvas','picture'].includes(tag)) return false;
|
|
79
|
+
const style = getComputedStyle(el);
|
|
80
|
+
const cls = el.getAttribute('class') || '';
|
|
81
|
+
const hasShadow = (style.boxShadow && style.boxShadow !== 'none') || /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
|
|
82
|
+
const hasBorder = /\bborder\b/.test(cls);
|
|
83
|
+
const hasRadius = parseFloat(style.borderRadius) > 0 || /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
|
|
84
|
+
const hasBg = (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)') || /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
|
|
85
|
+
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkLayout() {
|
|
89
|
+
const findings = [];
|
|
90
|
+
const flaggedEls = new Set();
|
|
91
|
+
|
|
92
|
+
for (const el of document.querySelectorAll('*')) {
|
|
93
|
+
if (!isCardLikeDOM(el) || flaggedEls.has(el)) continue;
|
|
94
|
+
const cls = el.getAttribute('class') || '';
|
|
95
|
+
const style = getComputedStyle(el);
|
|
96
|
+
if (style.position === 'absolute' || style.position === 'fixed') continue;
|
|
97
|
+
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
|
98
|
+
if ((el.textContent?.trim().length || 0) < 10) continue;
|
|
99
|
+
const rect = el.getBoundingClientRect();
|
|
100
|
+
if (rect.width < 50 || rect.height < 30) continue;
|
|
101
|
+
|
|
102
|
+
let parent = el.parentElement;
|
|
103
|
+
while (parent) {
|
|
104
|
+
if (isCardLikeDOM(parent)) { flaggedEls.add(el); break; }
|
|
105
|
+
parent = parent.parentElement;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const el of flaggedEls) {
|
|
110
|
+
let isAncestor = false;
|
|
111
|
+
for (const other of flaggedEls) {
|
|
112
|
+
if (other !== el && el.contains(other)) { isAncestor = true; break; }
|
|
113
|
+
}
|
|
114
|
+
if (!isAncestor) findings.push({ type: 'nested-cards', detail: 'Card inside card', el });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return findings;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Node page-level checks — take document/window as parameters
|
|
121
|
+
|
|
122
|
+
function checkPageTypography(doc, win) {
|
|
123
|
+
const findings = [];
|
|
124
|
+
|
|
125
|
+
const fonts = new Set();
|
|
126
|
+
const overusedFound = new Set();
|
|
127
|
+
|
|
128
|
+
for (const sheet of doc.styleSheets) {
|
|
129
|
+
let rules;
|
|
130
|
+
try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
|
|
131
|
+
if (!rules) continue;
|
|
132
|
+
for (const rule of rules) {
|
|
133
|
+
if (rule.type !== 1) continue;
|
|
134
|
+
const ff = rule.style?.fontFamily;
|
|
135
|
+
if (!ff) continue;
|
|
136
|
+
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
137
|
+
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
138
|
+
if (primary) {
|
|
139
|
+
fonts.add(primary);
|
|
140
|
+
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check Google Fonts links in HTML
|
|
146
|
+
const html = doc.documentElement?.outerHTML || '';
|
|
147
|
+
for (const f of extractGoogleFontFamilies(html)) {
|
|
148
|
+
fonts.add(f);
|
|
149
|
+
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
|
153
|
+
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
154
|
+
let fm;
|
|
155
|
+
while ((fm = ffRe.exec(html)) !== null) {
|
|
156
|
+
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
|
|
157
|
+
if (f && !GENERIC_FONTS.has(f)) {
|
|
158
|
+
fonts.add(f);
|
|
159
|
+
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const font of overusedFound) {
|
|
165
|
+
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Single font
|
|
169
|
+
if (fonts.size === 1) {
|
|
170
|
+
const els = doc.querySelectorAll('*');
|
|
171
|
+
if (els.length >= 20) {
|
|
172
|
+
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Flat type hierarchy
|
|
177
|
+
const sizes = new Set();
|
|
178
|
+
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
|
|
179
|
+
for (const el of textEls) {
|
|
180
|
+
const fontSize = parseFloat(win.getComputedStyle(el).fontSize);
|
|
181
|
+
// Filter out sub-8px values (jsdom doesn't resolve relative units properly)
|
|
182
|
+
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
|
|
183
|
+
}
|
|
184
|
+
if (sizes.size >= 3) {
|
|
185
|
+
const sorted = [...sizes].sort((a, b) => a - b);
|
|
186
|
+
const ratio = sorted[sorted.length - 1] / sorted[0];
|
|
187
|
+
if (ratio < 2.0) {
|
|
188
|
+
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => `${s}px`).join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return findings;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isCardLike(el, win) {
|
|
196
|
+
const tag = el.tagName.toLowerCase();
|
|
197
|
+
if (SAFE_TAGS.has(tag) || ['input', 'select', 'textarea', 'img', 'video', 'canvas', 'picture'].includes(tag)) return false;
|
|
198
|
+
|
|
199
|
+
const style = win.getComputedStyle(el);
|
|
200
|
+
const rawStyle = el.getAttribute?.('style') || '';
|
|
201
|
+
const cls = el.getAttribute?.('class') || '';
|
|
202
|
+
|
|
203
|
+
const hasShadow = (style.boxShadow && style.boxShadow !== 'none') ||
|
|
204
|
+
/\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls) || /box-shadow/i.test(rawStyle);
|
|
205
|
+
const hasBorder = /\bborder\b/.test(cls);
|
|
206
|
+
const widthPx = parseFloat(style.width) || 0;
|
|
207
|
+
const hasRadius = resolveBorderRadiusPx(el, style, widthPx, win) > 0 ||
|
|
208
|
+
/\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls) || /border-radius/i.test(rawStyle);
|
|
209
|
+
const hasBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls) ||
|
|
210
|
+
/background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle);
|
|
211
|
+
|
|
212
|
+
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function checkPageLayout(doc, win) {
|
|
216
|
+
const findings = [];
|
|
217
|
+
|
|
218
|
+
// Nested cards
|
|
219
|
+
const allEls = doc.querySelectorAll('*');
|
|
220
|
+
const flaggedEls = new Set();
|
|
221
|
+
for (const el of allEls) {
|
|
222
|
+
if (!isCardLike(el, win)) continue;
|
|
223
|
+
if (flaggedEls.has(el)) continue;
|
|
224
|
+
|
|
225
|
+
const tag = el.tagName.toLowerCase();
|
|
226
|
+
const cls = el.getAttribute?.('class') || '';
|
|
227
|
+
const rawStyle = el.getAttribute?.('style') || '';
|
|
228
|
+
|
|
229
|
+
if (['pre', 'code'].includes(tag)) continue;
|
|
230
|
+
if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
|
|
231
|
+
if ((el.textContent?.trim().length || 0) < 10) continue;
|
|
232
|
+
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
|
233
|
+
|
|
234
|
+
// Walk up to find card-like ancestor
|
|
235
|
+
let parent = el.parentElement;
|
|
236
|
+
while (parent) {
|
|
237
|
+
if (isCardLike(parent, win)) {
|
|
238
|
+
flaggedEls.add(el);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
parent = parent.parentElement;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Only report innermost nested cards
|
|
246
|
+
for (const el of flaggedEls) {
|
|
247
|
+
let isAncestorOfFlagged = false;
|
|
248
|
+
for (const other of flaggedEls) {
|
|
249
|
+
if (other !== el && el.contains(other)) {
|
|
250
|
+
isAncestorOfFlagged = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!isAncestorOfFlagged) {
|
|
255
|
+
findings.push({ id: 'nested-cards', snippet: `Card inside card (${el.tagName.toLowerCase()})` });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return findings;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
|
|
263
|
+
// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
|
|
264
|
+
// small warm tint (not white, not a strong color). The current reflex surface.
|
|
265
|
+
function isCreamColor(rgb) {
|
|
266
|
+
if (!rgb) return false;
|
|
267
|
+
const { r, g, b } = rgb;
|
|
268
|
+
if (Math.min(r, g, b) < 209) return false; // must be light
|
|
269
|
+
if (!(r >= g && g >= b)) return false; // warm ordering
|
|
270
|
+
const warmth = r - b;
|
|
271
|
+
return warmth >= 6 && warmth <= 48; // tinted, not white, not strong
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Tailwind background utilities that render as a warm off-white surface. The
|
|
275
|
+
// static engine doesn't fetch Tailwind's CSS, so a `bg-amber-50` on <body>
|
|
276
|
+
// resolves to nothing in computed style — catch it from the class list
|
|
277
|
+
// instead. Candidate tokens map to their actual Tailwind hex and are still
|
|
278
|
+
// filtered through isCreamColor, so neutral grays (stone) and over-saturated
|
|
279
|
+
// shades drop out on their own.
|
|
280
|
+
const TAILWIND_BG_HEX = {
|
|
281
|
+
'bg-amber-50': '#fffbeb', 'bg-amber-100': '#fef3c7',
|
|
282
|
+
'bg-orange-50': '#fff7ed', 'bg-orange-100': '#ffedd5',
|
|
283
|
+
'bg-yellow-50': '#fefce8',
|
|
284
|
+
'bg-stone-50': '#fafaf9', 'bg-stone-100': '#f5f5f4', 'bg-stone-200': '#e7e5e4',
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
function creamFromClassList(cls) {
|
|
288
|
+
if (!cls) return null;
|
|
289
|
+
// Arbitrary value: bg-[#f5f0e6] / bg-[rgb(245_240_230)] (underscores = spaces).
|
|
290
|
+
const arb = cls.match(/\bbg-\[([^\]]+)\]/);
|
|
291
|
+
if (arb && isCreamColor(parseAnyColor(arb[1].replace(/_/g, ' ')))) return `bg-[${arb[1]}]`;
|
|
292
|
+
// Named warm-light utilities.
|
|
293
|
+
for (const [tok, hex] of Object.entries(TAILWIND_BG_HEX)) {
|
|
294
|
+
if (new RegExp(`(^|\\s)${tok}($|\\s)`).test(cls) && isCreamColor(parseAnyColor(hex))) return tok;
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function checkCreamPalette(doc, win) {
|
|
300
|
+
const findings = [];
|
|
301
|
+
const body = doc.body || (doc.querySelector ? doc.querySelector('body') : null);
|
|
302
|
+
if (!body) return findings;
|
|
303
|
+
const html = doc.documentElement;
|
|
304
|
+
const getCS = (el) => (win ? win.getComputedStyle(el) : getComputedStyle(el));
|
|
305
|
+
|
|
306
|
+
// 1. Computed background — covers inline / <style> / linked CSS, and Tailwind
|
|
307
|
+
// once it's actually rendered (browser path).
|
|
308
|
+
let bg = readOwnBackgroundColor(body, getCS(body));
|
|
309
|
+
if (!bg || bg.a === 0) {
|
|
310
|
+
if (html) bg = readOwnBackgroundColor(html, getCS(html));
|
|
311
|
+
}
|
|
312
|
+
if (isCreamColor(bg)) {
|
|
313
|
+
findings.push({ id: 'cream-palette', snippet: `cream/beige page background rgb(${bg.r}, ${bg.g}, ${bg.b})` });
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. Tailwind class fallback — for the static path, where utility classes
|
|
318
|
+
// never resolve to computed CSS.
|
|
319
|
+
for (const el of [body, html]) {
|
|
320
|
+
const tok = creamFromClassList(el?.getAttribute ? el.getAttribute('class') : '');
|
|
321
|
+
if (tok) {
|
|
322
|
+
findings.push({ id: 'cream-palette', snippet: `cream/beige page background (Tailwind ${tok})` });
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return findings;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Oversized hero headline ────────────────────────────────────────────────
|
|
330
|
+
// Fires when a *long* headline is set at display size and actually dominates
|
|
331
|
+
// the viewport. A punchy one- or two-word headline at the same size is a
|
|
332
|
+
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
|
333
|
+
// pass too — length and viewport share together are the tell.
|
|
334
|
+
const OVERSIZED_H1_FONT_PX = 72;
|
|
335
|
+
const OVERSIZED_H1_MIN_CHARS = 40;
|
|
336
|
+
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
|
337
|
+
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
|
338
|
+
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
|
339
|
+
if (tag !== 'h1') return [];
|
|
340
|
+
const textLen = headingText.length;
|
|
341
|
+
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
|
342
|
+
let viewportDetail = '';
|
|
343
|
+
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
|
344
|
+
const heightRatio = rect.height / viewportHeight;
|
|
345
|
+
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
|
346
|
+
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
|
347
|
+
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
|
348
|
+
if (!dominatesViewport) return [];
|
|
349
|
+
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
|
350
|
+
}
|
|
351
|
+
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
|
352
|
+
}
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function checkElementOversizedH1(el, _style, tag, window) {
|
|
357
|
+
if (tag !== 'h1') return [];
|
|
358
|
+
const fontSize = resolveFontSizePx(el, window);
|
|
359
|
+
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
360
|
+
return checkOversizedH1({ tag, fontSize, headingText });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function checkElementOversizedH1DOM(el) {
|
|
364
|
+
const tag = el.tagName.toLowerCase();
|
|
365
|
+
if (tag !== 'h1') return [];
|
|
366
|
+
const style = getComputedStyle(el);
|
|
367
|
+
const fontSize = parseFloat(style.fontSize) || 0;
|
|
368
|
+
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
369
|
+
const rect = el.getBoundingClientRect();
|
|
370
|
+
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
|
371
|
+
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
|
372
|
+
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
|
376
|
+
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
|
377
|
+
|
|
378
|
+
function shadowLayerAlpha(layer) {
|
|
379
|
+
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
|
380
|
+
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
|
381
|
+
if (!match) return 1;
|
|
382
|
+
if (match[0].toLowerCase() === 'transparent') return 0;
|
|
383
|
+
const parsed = parseAnyColor(match[0]);
|
|
384
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
|
388
|
+
if (!boxShadow || boxShadow === 'none') return 0;
|
|
389
|
+
let maxBlur = 0;
|
|
390
|
+
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
|
391
|
+
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
|
392
|
+
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
|
393
|
+
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
|
394
|
+
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
|
395
|
+
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
|
396
|
+
// both reduce to the same numbers here.
|
|
397
|
+
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
|
398
|
+
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
|
399
|
+
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
|
400
|
+
}
|
|
401
|
+
return maxBlur;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function cssColorAlpha(value) {
|
|
405
|
+
if (cssColorIsTransparent(value)) return 0;
|
|
406
|
+
const parsed = parseAnyColor(value);
|
|
407
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
|
411
|
+
const visibleThinBorders = borderWidths
|
|
412
|
+
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
|
413
|
+
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
|
414
|
+
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
|
415
|
+
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
|
416
|
+
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
|
417
|
+
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
|
418
|
+
}
|
|
419
|
+
return [];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function borderWidthsFromStyle(style) {
|
|
423
|
+
return [
|
|
424
|
+
parseFloat(style.borderTopWidth) || 0,
|
|
425
|
+
parseFloat(style.borderRightWidth) || 0,
|
|
426
|
+
parseFloat(style.borderBottomWidth) || 0,
|
|
427
|
+
parseFloat(style.borderLeftWidth) || 0,
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function borderColorsFromStyle(style) {
|
|
432
|
+
return [
|
|
433
|
+
style.borderTopColor || '',
|
|
434
|
+
style.borderRightColor || '',
|
|
435
|
+
style.borderBottomColor || '',
|
|
436
|
+
style.borderLeftColor || '',
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function checkElementGptBorderShadow(_el, style) {
|
|
441
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function checkElementGptBorderShadowDOM(el) {
|
|
445
|
+
const style = getComputedStyle(el);
|
|
446
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export {
|
|
450
|
+
checkTypography,
|
|
451
|
+
isCardLikeDOM,
|
|
452
|
+
checkLayout,
|
|
453
|
+
checkPageTypography,
|
|
454
|
+
isCardLike,
|
|
455
|
+
checkPageLayout,
|
|
456
|
+
isCreamColor,
|
|
457
|
+
checkCreamPalette,
|
|
458
|
+
checkOversizedH1,
|
|
459
|
+
checkElementOversizedH1,
|
|
460
|
+
checkElementOversizedH1DOM,
|
|
461
|
+
shadowMaxBlurPx,
|
|
462
|
+
checkGptThinBorderWideShadow,
|
|
463
|
+
checkElementGptBorderShadow,
|
|
464
|
+
checkElementGptBorderShadowDOM,
|
|
465
|
+
};
|