@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,286 @@
|
|
|
1
|
+
import { parseRgb } from '../shared/color.mjs';
|
|
2
|
+
import {
|
|
3
|
+
checkBorders,
|
|
4
|
+
isEmojiOnlyText,
|
|
5
|
+
checkColors,
|
|
6
|
+
HEADING_TAGS,
|
|
7
|
+
checkIconTile,
|
|
8
|
+
checkItalicSerif,
|
|
9
|
+
checkHeroEyebrow,
|
|
10
|
+
checkRepeatedSectionKickers,
|
|
11
|
+
checkMotion,
|
|
12
|
+
checkGlow,
|
|
13
|
+
} from './checks-pure.mjs';
|
|
14
|
+
import {
|
|
15
|
+
readOwnBackgroundColor,
|
|
16
|
+
resolveBackground,
|
|
17
|
+
resolveGradientStops,
|
|
18
|
+
resolveBorderRadiusPx,
|
|
19
|
+
} from './checks-background.mjs';
|
|
20
|
+
import { resolveVarRefs, parseColorResolved } from './checks-css-values.mjs';
|
|
21
|
+
import { collectRepeatedSectionKickerCandidates } from './checks-dom-effects.mjs';
|
|
22
|
+
import {
|
|
23
|
+
resolveFontSizePx,
|
|
24
|
+
resolveLengthPx,
|
|
25
|
+
checkQuality,
|
|
26
|
+
} from './checks-quality.mjs';
|
|
27
|
+
|
|
28
|
+
function checkElementQualityDOM(el) {
|
|
29
|
+
const tag = el.tagName.toLowerCase();
|
|
30
|
+
const style = getComputedStyle(el);
|
|
31
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
|
32
|
+
const textLen = el.textContent?.trim().length || 0;
|
|
33
|
+
// Browser getComputedStyle resolves everything to px — direct parseFloat
|
|
34
|
+
// works.
|
|
35
|
+
const fontSize = parseFloat(style.fontSize) || 16;
|
|
36
|
+
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
|
37
|
+
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
|
38
|
+
const rect = el.getBoundingClientRect();
|
|
39
|
+
const lineMax = (typeof window !== 'undefined' && window.__MONODESIGN_CONFIG__?.lineLengthMax) || 80;
|
|
40
|
+
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
|
41
|
+
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax, viewportWidth, win: typeof window !== 'undefined' ? window : null });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Pure page-level skipped-heading walk. Takes a Document so it works in both
|
|
45
|
+
// the browser and jsdom.
|
|
46
|
+
function checkPageQualityFromDoc(doc) {
|
|
47
|
+
const findings = [];
|
|
48
|
+
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
49
|
+
let prevLevel = 0;
|
|
50
|
+
let prevText = '';
|
|
51
|
+
for (const h of headings) {
|
|
52
|
+
const level = parseInt(h.tagName[1], 10);
|
|
53
|
+
const text = (h.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60);
|
|
54
|
+
if (prevLevel > 0 && level > prevLevel + 1) {
|
|
55
|
+
findings.push({
|
|
56
|
+
id: 'skipped-heading',
|
|
57
|
+
snippet: `<h${prevLevel}> "${prevText}" followed by <h${level}> "${text}" (missing h${prevLevel + 1})`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
prevLevel = level;
|
|
61
|
+
prevText = text;
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Browser adapter (returns the legacy { type, detail } shape used by the overlay loop)
|
|
67
|
+
function checkPageQualityDOM() {
|
|
68
|
+
return checkPageQualityFromDoc(document).map(f => ({ type: f.id, detail: f.snippet }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Node adapters — take pre-extracted jsdom computed style
|
|
72
|
+
|
|
73
|
+
// jsdom doesn't lay out OR resolve em/rem/% to px — so we pre-resolve every
|
|
74
|
+
// CSS length the rule needs ourselves (walking the parent chain for
|
|
75
|
+
// font-size inheritance), and pass `rect: null` to skip the two rules that
|
|
76
|
+
// genuinely need element rects (line-length, cramped-padding).
|
|
77
|
+
function checkElementQuality(el, style, tag, window) {
|
|
78
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 10);
|
|
79
|
+
const textLen = el.textContent?.trim().length || 0;
|
|
80
|
+
const fontSize = resolveFontSizePx(el, window);
|
|
81
|
+
const lineHeightPx = resolveLengthPx(style.lineHeight, fontSize);
|
|
82
|
+
const letterSpacingPx = resolveLengthPx(style.letterSpacing, fontSize);
|
|
83
|
+
return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkElementBorders(tag, style, overrides, resolvedRadius) {
|
|
87
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
88
|
+
const widths = {}, colors = {};
|
|
89
|
+
for (const s of sides) {
|
|
90
|
+
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
|
91
|
+
colors[s] = style[`border${s}Color`] || '';
|
|
92
|
+
// jsdom silently drops any border shorthand containing var(), leaving
|
|
93
|
+
// both width and color empty on the computed style. When the detectHtml
|
|
94
|
+
// pre-pass pulled a resolved value off the rule, use it to fill in the
|
|
95
|
+
// missing side so the side-tab check can run. Real browsers resolve
|
|
96
|
+
// var() natively, so this fallback is a no-op in the browser path.
|
|
97
|
+
if (widths[s] === 0 && overrides && overrides[s]) {
|
|
98
|
+
widths[s] = overrides[s].width;
|
|
99
|
+
colors[s] = overrides[s].color;
|
|
100
|
+
} else if (colors[s]?.startsWith('var(') && overrides?.[s]) {
|
|
101
|
+
// Longhand case: jsdom kept the width but left the color as the
|
|
102
|
+
// literal `var(...)` string. Substitute the resolved color.
|
|
103
|
+
colors[s] = overrides[s].color;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// resolvedRadius lets the caller pre-resolve the radius via
|
|
107
|
+
// resolveBorderRadiusPx so the value survives jsdom 29.1.0's broken
|
|
108
|
+
// shorthand serialization. Falls back to the computed value for tests
|
|
109
|
+
// and browser callers that don't pre-resolve.
|
|
110
|
+
const radius = resolvedRadius != null
|
|
111
|
+
? resolvedRadius
|
|
112
|
+
: (parseFloat(style.borderRadius) || 0);
|
|
113
|
+
return checkBorders(tag, widths, colors, radius);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
|
117
|
+
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
118
|
+
const hasDirectText = directText.trim().length > 0;
|
|
119
|
+
|
|
120
|
+
const effectiveBg = resolveBackground(el, window, customPropMap);
|
|
121
|
+
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
|
122
|
+
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
|
123
|
+
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
|
124
|
+
let textColor = customPropMap ? parseColorResolved(style.color, customPropMap) : null;
|
|
125
|
+
if (!textColor) textColor = parseRgb(style.color);
|
|
126
|
+
|
|
127
|
+
// Anchor-inherit FP workaround: jsdom's UA stylesheet has `:link { color:
|
|
128
|
+
// blue }` at high specificity. The page's `a { color: inherit }` rule
|
|
129
|
+
// (Tailwind v4 preflight) loses to jsdom even though it WINS in real
|
|
130
|
+
// browsers (Chrome's UA wraps :link in :where() — zero specificity).
|
|
131
|
+
// When the page declares the inherit rule AND we see jsdom's default
|
|
132
|
+
// link blue on an anchor, walk to the nearest non-anchor ancestor and
|
|
133
|
+
// use its color instead.
|
|
134
|
+
if (
|
|
135
|
+
hasAnchorInheritRule &&
|
|
136
|
+
textColor &&
|
|
137
|
+
textColor.r === 0 && textColor.g === 0 && textColor.b === 238 &&
|
|
138
|
+
(tag === 'a' || el.closest?.('a'))
|
|
139
|
+
) {
|
|
140
|
+
let cur = el.parentElement;
|
|
141
|
+
while (cur && cur.tagName !== 'HTML') {
|
|
142
|
+
if (cur.tagName !== 'A') {
|
|
143
|
+
const ps = window.getComputedStyle(cur);
|
|
144
|
+
const inh = (customPropMap ? parseColorResolved(ps.color, customPropMap) : null) || parseRgb(ps.color);
|
|
145
|
+
if (inh && !(inh.r === 0 && inh.g === 0 && inh.b === 238)) {
|
|
146
|
+
textColor = inh;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
cur = cur.parentElement;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return checkColors({
|
|
155
|
+
tag,
|
|
156
|
+
textColor,
|
|
157
|
+
bgColor: readOwnBackgroundColor(el, style),
|
|
158
|
+
effectiveBg,
|
|
159
|
+
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
|
|
160
|
+
fontSize: parseFloat(style.fontSize) || 16,
|
|
161
|
+
fontWeight: parseInt(style.fontWeight, 10) || 400,
|
|
162
|
+
hasDirectText,
|
|
163
|
+
isEmojiOnly: isEmojiOnlyText(directText),
|
|
164
|
+
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
165
|
+
bgImage: style.backgroundImage || '',
|
|
166
|
+
classList: el.getAttribute?.('class') || el.className || '',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function checkElementIconTile(el, tag, window) {
|
|
171
|
+
if (!HEADING_TAGS.has(tag)) return [];
|
|
172
|
+
const sibling = el.previousElementSibling;
|
|
173
|
+
if (!sibling) return [];
|
|
174
|
+
|
|
175
|
+
const sibStyle = window.getComputedStyle(sibling);
|
|
176
|
+
// jsdom doesn't lay out — read explicit pixel dimensions from CSS instead.
|
|
177
|
+
const sibWidth = parseFloat(sibStyle.width) || 0;
|
|
178
|
+
const sibHeight = parseFloat(sibStyle.height) || 0;
|
|
179
|
+
|
|
180
|
+
const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
|
|
181
|
+
let iconWidth = 0;
|
|
182
|
+
if (iconChild) {
|
|
183
|
+
const iconStyle = window.getComputedStyle(iconChild);
|
|
184
|
+
iconWidth = parseFloat(iconStyle.width) || parseFloat(iconChild.getAttribute('width')) || 0;
|
|
185
|
+
}
|
|
186
|
+
// Or: tile contains an emoji/symbol character directly as its only content
|
|
187
|
+
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
188
|
+
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
|
|
189
|
+
|
|
190
|
+
return checkIconTile({
|
|
191
|
+
headingTag: tag,
|
|
192
|
+
headingText: el.textContent || '',
|
|
193
|
+
headingTop: 0, // jsdom: no layout, skip vertical-stacking gate
|
|
194
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
195
|
+
siblingWidth: sibWidth,
|
|
196
|
+
siblingHeight: sibHeight,
|
|
197
|
+
siblingBottom: 0,
|
|
198
|
+
siblingBgColor: parseRgb(sibStyle.backgroundColor),
|
|
199
|
+
siblingBgImage: sibStyle.backgroundImage || '',
|
|
200
|
+
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
|
|
201
|
+
siblingBorderRadius: resolveBorderRadiusPx(sibling, sibStyle, sibWidth, window),
|
|
202
|
+
hasIconChild: !!iconChild || hasInlineEmojiIcon,
|
|
203
|
+
iconChildWidth: iconWidth,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkElementItalicSerif(el, style, tag) {
|
|
208
|
+
if (tag !== 'h1' && tag !== 'h2') return [];
|
|
209
|
+
return checkItalicSerif({
|
|
210
|
+
tag,
|
|
211
|
+
fontStyle: style.fontStyle || '',
|
|
212
|
+
fontFamily: style.fontFamily || '',
|
|
213
|
+
fontSize: parseFloat(style.fontSize) || 0,
|
|
214
|
+
headingText: el.textContent || '',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
|
|
219
|
+
if (tag !== 'h1') return [];
|
|
220
|
+
const sibling = el.previousElementSibling;
|
|
221
|
+
if (!sibling) return [];
|
|
222
|
+
const sibStyle = window.getComputedStyle(sibling);
|
|
223
|
+
// Resolve Tailwind v4 CSS-variable wrappers (font-weight:var(--font-weight-bold)
|
|
224
|
+
// etc.) before parsing. jsdom returns these verbatim from getComputedStyle;
|
|
225
|
+
// without resolution every style-based gate fails silently on Tailwind v4 builds.
|
|
226
|
+
const fontSizeRaw = customPropMap ? resolveVarRefs(sibStyle.fontSize, customPropMap) : sibStyle.fontSize;
|
|
227
|
+
const fontWeightRaw = customPropMap ? resolveVarRefs(sibStyle.fontWeight, customPropMap) : sibStyle.fontWeight;
|
|
228
|
+
const letterSpacingRaw = customPropMap ? resolveVarRefs(sibStyle.letterSpacing, customPropMap) : sibStyle.letterSpacing;
|
|
229
|
+
const colorRaw = customPropMap ? resolveVarRefs(sibStyle.color, customPropMap) : sibStyle.color;
|
|
230
|
+
const headingFontSizeRaw = customPropMap ? resolveVarRefs(style.fontSize, customPropMap) : style.fontSize;
|
|
231
|
+
const siblingFontSize = parseFloat(fontSizeRaw) || 0;
|
|
232
|
+
// resolveLengthPx returns null for 'normal' / 'auto'; coerce to 0 so the
|
|
233
|
+
// gate falls through cleanly. jsdom returns letter-spacing verbatim
|
|
234
|
+
// (e.g. '0.15em'), unlike real browsers, so this conversion is required.
|
|
235
|
+
return checkHeroEyebrow({
|
|
236
|
+
headingTag: tag,
|
|
237
|
+
headingText: el.textContent || '',
|
|
238
|
+
headingFontSize: parseFloat(headingFontSizeRaw) || 0,
|
|
239
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
240
|
+
siblingText: sibling.textContent || '',
|
|
241
|
+
siblingTextTransform: sibStyle.textTransform || '',
|
|
242
|
+
siblingFontSize,
|
|
243
|
+
siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
|
|
244
|
+
siblingFontWeight: fontWeightRaw || '',
|
|
245
|
+
siblingColor: colorRaw || '',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function checkRepeatedSectionKickersFromDoc(doc, win) {
|
|
250
|
+
const candidates = collectRepeatedSectionKickerCandidates(
|
|
251
|
+
doc,
|
|
252
|
+
(el) => win.getComputedStyle(el),
|
|
253
|
+
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
|
254
|
+
);
|
|
255
|
+
return checkRepeatedSectionKickers({ candidates });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function checkElementMotion(tag, style) {
|
|
259
|
+
return checkMotion({
|
|
260
|
+
tag,
|
|
261
|
+
transitionProperty: style.transitionProperty || '',
|
|
262
|
+
animationName: style.animationName || '',
|
|
263
|
+
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
|
|
264
|
+
classList: '',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function checkElementGlow(tag, style, effectiveBg) {
|
|
269
|
+
if (!style.boxShadow || style.boxShadow === 'none') return [];
|
|
270
|
+
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export {
|
|
274
|
+
checkElementQualityDOM,
|
|
275
|
+
checkPageQualityFromDoc,
|
|
276
|
+
checkPageQualityDOM,
|
|
277
|
+
checkElementQuality,
|
|
278
|
+
checkElementBorders,
|
|
279
|
+
checkElementColors,
|
|
280
|
+
checkElementIconTile,
|
|
281
|
+
checkElementItalicSerif,
|
|
282
|
+
checkElementHeroEyebrow,
|
|
283
|
+
checkRepeatedSectionKickersFromDoc,
|
|
284
|
+
checkElementMotion,
|
|
285
|
+
checkElementGlow,
|
|
286
|
+
};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex-on-HTML checks shared between browser and Node page-level detection.
|
|
3
|
+
* These don't need DOM access, just the raw HTML string.
|
|
4
|
+
*/
|
|
5
|
+
function checkHtmlPatterns(html) {
|
|
6
|
+
const findings = [];
|
|
7
|
+
|
|
8
|
+
// --- Color ---
|
|
9
|
+
|
|
10
|
+
// AI color palette: purple/violet
|
|
11
|
+
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
|
12
|
+
if (purpleHexRe.test(html)) {
|
|
13
|
+
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
|
14
|
+
if (purpleTextRe.test(html)) {
|
|
15
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Gradient text (background-clip: text + gradient)
|
|
20
|
+
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
|
|
21
|
+
let gm;
|
|
22
|
+
while ((gm = gradientRe.exec(html)) !== null) {
|
|
23
|
+
const start = Math.max(0, gm.index - 200);
|
|
24
|
+
const context = html.substring(start, gm.index + gm[0].length + 200);
|
|
25
|
+
if (/gradient/i.test(context)) {
|
|
26
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
|
|
31
|
+
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Layout ---
|
|
35
|
+
|
|
36
|
+
// Monotonous spacing
|
|
37
|
+
const spacingValues = [];
|
|
38
|
+
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
|
39
|
+
let sm;
|
|
40
|
+
while ((sm = spacingRe.exec(html)) !== null) {
|
|
41
|
+
const v = parseInt(sm[1], 10);
|
|
42
|
+
if (v > 0 && v < 200) spacingValues.push(v);
|
|
43
|
+
}
|
|
44
|
+
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
|
45
|
+
while ((sm = gapRe.exec(html)) !== null) {
|
|
46
|
+
spacingValues.push(parseInt(sm[1], 10));
|
|
47
|
+
}
|
|
48
|
+
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
|
49
|
+
while ((sm = twSpaceRe.exec(html)) !== null) {
|
|
50
|
+
spacingValues.push(parseInt(sm[1], 10) * 4);
|
|
51
|
+
}
|
|
52
|
+
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
|
53
|
+
while ((sm = remSpacingRe.exec(html)) !== null) {
|
|
54
|
+
const v = Math.round(parseFloat(sm[1]) * 16);
|
|
55
|
+
if (v > 0 && v < 200) spacingValues.push(v);
|
|
56
|
+
}
|
|
57
|
+
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
|
|
58
|
+
if (roundedSpacing.length >= 10) {
|
|
59
|
+
const counts = {};
|
|
60
|
+
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
|
|
61
|
+
const maxCount = Math.max(...Object.values(counts));
|
|
62
|
+
const dominantPct = maxCount / roundedSpacing.length;
|
|
63
|
+
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
|
|
64
|
+
if (dominantPct > 0.6 && unique.length <= 3) {
|
|
65
|
+
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
|
66
|
+
findings.push({
|
|
67
|
+
id: 'monotonous-spacing',
|
|
68
|
+
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Motion ---
|
|
74
|
+
|
|
75
|
+
// Bounce/elastic animation names
|
|
76
|
+
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
|
|
77
|
+
const bounceMatch = bounceRe.exec(html);
|
|
78
|
+
if (bounceMatch) {
|
|
79
|
+
const animationToken = bounceMatch[1]
|
|
80
|
+
.split(/[,\s]+/)
|
|
81
|
+
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
82
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Overshoot cubic-bezier
|
|
86
|
+
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
|
|
87
|
+
let bm;
|
|
88
|
+
while ((bm = bezierRe.exec(html)) !== null) {
|
|
89
|
+
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
|
90
|
+
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
91
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Layout property transitions
|
|
97
|
+
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
|
|
98
|
+
let tm;
|
|
99
|
+
while ((tm = transRe.exec(html)) !== null) {
|
|
100
|
+
const val = tm[1].toLowerCase();
|
|
101
|
+
if (/\ball\b/.test(val)) continue;
|
|
102
|
+
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
|
|
103
|
+
if (found) {
|
|
104
|
+
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Dark glow ---
|
|
110
|
+
|
|
111
|
+
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;
|
|
112
|
+
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
|
|
113
|
+
if (darkBgRe.test(html) || twDarkBg.test(html)) {
|
|
114
|
+
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
|
|
115
|
+
let shm;
|
|
116
|
+
while ((shm = shadowRe.exec(html)) !== null) {
|
|
117
|
+
const val = shm[1];
|
|
118
|
+
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
|
119
|
+
if (!colorMatch) continue;
|
|
120
|
+
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
|
|
121
|
+
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
|
|
122
|
+
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
|
|
123
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
124
|
+
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Provider tells (gated): repeating-gradient stripes (GPT) ---
|
|
131
|
+
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
|
|
132
|
+
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
|
|
136
|
+
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
|
|
137
|
+
// transparent 1px)` layers (one per axis) tiled by a repeating
|
|
138
|
+
// `background-size` cell. Both signals must co-occur in the SAME style block
|
|
139
|
+
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
|
|
140
|
+
// tiling background-size is a fixed crosshair, not a grid, and a single
|
|
141
|
+
// hairline is a legitimate ruled line. Scoping to one block also stops
|
|
142
|
+
// unrelated single-axis rules on separate elements from adding up across the
|
|
143
|
+
// page. Count hairlines only inside `background`/`background-image` values so
|
|
144
|
+
// a hairline in an unrelated property (mask-image, border-image) can't stand
|
|
145
|
+
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
|
|
146
|
+
// nested parens, so match the hairline stop directly rather than parsing
|
|
147
|
+
// whole gradient layers.
|
|
148
|
+
{
|
|
149
|
+
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
|
150
|
+
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
|
151
|
+
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
|
152
|
+
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
|
153
|
+
let blk;
|
|
154
|
+
while ((blk = blockRe.exec(html)) !== null) {
|
|
155
|
+
const block = blk[1] || blk[2] || blk[3] || '';
|
|
156
|
+
if (!gridSizeRe.test(block)) continue;
|
|
157
|
+
let hairlineCount = 0;
|
|
158
|
+
let bm;
|
|
159
|
+
bgDeclRe.lastIndex = 0;
|
|
160
|
+
while ((bm = bgDeclRe.exec(block)) !== null) {
|
|
161
|
+
const stops = bm[1].match(hairlineRe);
|
|
162
|
+
if (stops) hairlineCount += stops.length;
|
|
163
|
+
}
|
|
164
|
+
if (hairlineCount >= 2) {
|
|
165
|
+
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
|
|
172
|
+
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
|
|
173
|
+
// runs in the bundled browser path too, not just the CLI/static path.
|
|
174
|
+
{
|
|
175
|
+
const bodyText = html
|
|
176
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
|
177
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
|
178
|
+
.replace(/<[^>]+>/g, ' ');
|
|
179
|
+
const tm = /\b(\w+)\s+theater\b/i.exec(bodyText);
|
|
180
|
+
if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Provider tells (gated): image hover transform (Gemini) ---
|
|
184
|
+
// A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
|
|
185
|
+
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
|
186
|
+
// mechanism is its own finding.
|
|
187
|
+
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
|
188
|
+
if (imgHoverCss.test(html)) {
|
|
189
|
+
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
|
|
190
|
+
}
|
|
191
|
+
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
|
192
|
+
let im;
|
|
193
|
+
while ((im = imgTagRe.exec(html)) !== null) {
|
|
194
|
+
if (/\bhover:(?:scale|rotate|translate|skew)-/.test(im[1])) {
|
|
195
|
+
findings.push({ id: 'image-hover-transform', snippet: 'Tailwind hover transform on <img>' });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return findings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
checkHtmlPatterns,
|
|
204
|
+
};
|