@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,453 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BORDER_SAFE_TAGS,
|
|
3
|
+
GENERIC_FONTS,
|
|
4
|
+
KNOWN_SERIF_FONTS,
|
|
5
|
+
SAFE_TAGS,
|
|
6
|
+
WCAG_LARGE_BOLD_TEXT_PX,
|
|
7
|
+
WCAG_LARGE_TEXT_PX,
|
|
8
|
+
} from '../shared/constants.mjs';
|
|
9
|
+
import {
|
|
10
|
+
colorToHex,
|
|
11
|
+
contrastRatio,
|
|
12
|
+
getHue,
|
|
13
|
+
hasChroma,
|
|
14
|
+
isNeutralColor,
|
|
15
|
+
parseRgb,
|
|
16
|
+
relativeLuminance,
|
|
17
|
+
} from '../shared/color.mjs';
|
|
18
|
+
|
|
19
|
+
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
|
20
|
+
|
|
21
|
+
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function checkBorders(tag, widths, colors, radius) {
|
|
24
|
+
if (BORDER_SAFE_TAGS.has(tag)) return [];
|
|
25
|
+
const findings = [];
|
|
26
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
27
|
+
|
|
28
|
+
for (const side of sides) {
|
|
29
|
+
const w = widths[side];
|
|
30
|
+
if (w < 1 || isNeutralColor(colors[side])) continue;
|
|
31
|
+
|
|
32
|
+
const otherSides = sides.filter(s => s !== side);
|
|
33
|
+
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
|
34
|
+
if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
|
|
35
|
+
|
|
36
|
+
const sn = side.toLowerCase();
|
|
37
|
+
const isSide = side === 'Left' || side === 'Right';
|
|
38
|
+
|
|
39
|
+
if (isSide) {
|
|
40
|
+
if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
41
|
+
else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
|
|
42
|
+
} else {
|
|
43
|
+
if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Returns true if the given text is composed entirely of emoji characters
|
|
51
|
+
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
|
52
|
+
// regardless of CSS `color`, so contrast checks against the element's text
|
|
53
|
+
// color are meaningless for these nodes.
|
|
54
|
+
const EMOJI_CHAR_RE = /(?:[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]|\u{FE0F}|\u{200D}|[\u{1F3FB}-\u{1F3FF}])/u;
|
|
55
|
+
const EMOJI_CHARS_GLOBAL = /(?:[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]|\u{FE0F}|\u{200D}|[\u{1F3FB}-\u{1F3FF}])/gu;
|
|
56
|
+
function isEmojiOnlyText(text) {
|
|
57
|
+
if (!text) return false;
|
|
58
|
+
if (!EMOJI_CHAR_RE.test(text)) return false;
|
|
59
|
+
return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function checkColors(opts) {
|
|
63
|
+
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
|
|
64
|
+
if (SAFE_TAGS.has(tag)) {
|
|
65
|
+
// Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
|
|
66
|
+
// exists to suppress contrast noise on inline links and unstyled controls,
|
|
67
|
+
// where the element has no own background and the contrast against the
|
|
68
|
+
// ancestor surface is already the intended visual. When the element has
|
|
69
|
+
// its own opaque background and direct text, it is a styled button — and
|
|
70
|
+
// contrast on its own surface is a real, frequent bug worth flagging.
|
|
71
|
+
const isStyledButton = (tag === 'a' || tag === 'button')
|
|
72
|
+
&& hasDirectText
|
|
73
|
+
&& bgColor && bgColor.a > 0.5;
|
|
74
|
+
if (!isStyledButton) return [];
|
|
75
|
+
}
|
|
76
|
+
const findings = [];
|
|
77
|
+
|
|
78
|
+
if (hasDirectText && textColor && !isEmojiOnly) {
|
|
79
|
+
// Run background-dependent checks against either a solid bg or, if the
|
|
80
|
+
// ancestor is a gradient, against every gradient stop (use the worst case).
|
|
81
|
+
const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops?.length ? effectiveBgStops : null);
|
|
82
|
+
if (bgs) {
|
|
83
|
+
// Gray on colored background — flag if every stop is chromatic
|
|
84
|
+
const textLum = relativeLuminance(textColor);
|
|
85
|
+
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
|
86
|
+
if (isGray && bgs.every(b => hasChroma(b, 40))) {
|
|
87
|
+
const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
|
|
88
|
+
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Low contrast (WCAG AA) — worst case across all bg stops
|
|
92
|
+
const ratios = bgs.map(b => contrastRatio(textColor, b));
|
|
93
|
+
let worstIdx = 0;
|
|
94
|
+
for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
|
|
95
|
+
const ratio = ratios[worstIdx];
|
|
96
|
+
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
|
97
|
+
const threshold = isLargeText ? 3.0 : 4.5;
|
|
98
|
+
if (ratio < threshold) {
|
|
99
|
+
// Skip the false-positive class where text has alpha < 1 AND we
|
|
100
|
+
// couldn't find an opaque ancestor (effectiveBg is null, we're
|
|
101
|
+
// comparing against gradient-stop fallback). In jsdom mode the
|
|
102
|
+
// detector can't resolve `var(--X)` color tokens, so a dark
|
|
103
|
+
// section sitting between the text and the body's decorative
|
|
104
|
+
// gradient is invisible to us — we end up measuring contrast
|
|
105
|
+
// against the body's paper-grain noise instead of the real
|
|
106
|
+
// local bg. Real low-contrast bugs use alpha=1 and have a
|
|
107
|
+
// resolvable opaque ancestor; semi-transparent Tailwind tokens
|
|
108
|
+
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
|
109
|
+
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
|
110
|
+
if (!isAlphaFallbackFP) {
|
|
111
|
+
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// AI palette: purple/violet on headings
|
|
117
|
+
if (hasChroma(textColor, 50)) {
|
|
118
|
+
const hue = getHue(textColor);
|
|
119
|
+
if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
|
|
120
|
+
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Gradient text
|
|
126
|
+
if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
|
|
127
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Tailwind class checks
|
|
131
|
+
if (classList) {
|
|
132
|
+
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
|
133
|
+
|
|
134
|
+
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
|
135
|
+
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
|
136
|
+
if (grayMatch && colorBgMatch) {
|
|
137
|
+
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
|
|
141
|
+
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
|
|
145
|
+
if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
|
|
146
|
+
findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
|
|
150
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return findings;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
|
|
158
|
+
if (!hasShadow && !hasBorder) return false;
|
|
159
|
+
return hasRadius || hasBg;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
163
|
+
|
|
164
|
+
// Pure check: given a heading and metrics about its previousElementSibling,
|
|
165
|
+
// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
|
|
166
|
+
//
|
|
167
|
+
// Triggers when ALL of the following hold for the sibling:
|
|
168
|
+
// • size 32–128px on both axes (not too small, not a hero image)
|
|
169
|
+
// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
|
|
170
|
+
// • has a non-transparent background-color, background-image, OR a visible border
|
|
171
|
+
// (covers solid colors, white-with-border, gradients — anything that visually
|
|
172
|
+
// defines a tile)
|
|
173
|
+
// • border-radius < width/2 (excludes round avatars; rounded squares pass)
|
|
174
|
+
// • contains an <svg> or icon-class <i> element that's smaller than the tile
|
|
175
|
+
// • the tile sits above the heading (its bottom is above the heading's top)
|
|
176
|
+
function checkIconTile(opts) {
|
|
177
|
+
const { headingTag, headingText, headingTop,
|
|
178
|
+
siblingTag, siblingWidth, siblingHeight, siblingBottom,
|
|
179
|
+
siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
|
|
180
|
+
hasIconChild, iconChildWidth } = opts;
|
|
181
|
+
if (!HEADING_TAGS.has(headingTag)) return [];
|
|
182
|
+
if (!siblingTag) return [];
|
|
183
|
+
// Don't recurse into nested headings (e.g. h2 above h3 in a section header)
|
|
184
|
+
if (HEADING_TAGS.has(siblingTag)) return [];
|
|
185
|
+
|
|
186
|
+
// Size window: 32–128px on each axis
|
|
187
|
+
if (!(siblingWidth >= 32 && siblingWidth <= 128)) return [];
|
|
188
|
+
if (!(siblingHeight >= 32 && siblingHeight <= 128)) return [];
|
|
189
|
+
|
|
190
|
+
// Squarish aspect ratio
|
|
191
|
+
const ratio = siblingWidth / siblingHeight;
|
|
192
|
+
if (ratio < 0.7 || ratio > 1.4) return [];
|
|
193
|
+
|
|
194
|
+
// Must have something that visually defines the tile
|
|
195
|
+
const bgVisible = (siblingBgColor && siblingBgColor.a > 0.1)
|
|
196
|
+
|| (siblingBgImage && siblingBgImage !== 'none' && siblingBgImage !== '');
|
|
197
|
+
const borderVisible = siblingBorderWidth > 0;
|
|
198
|
+
if (!bgVisible && !borderVisible) return [];
|
|
199
|
+
|
|
200
|
+
// Exclude circles (avatars). Rounded squares pass.
|
|
201
|
+
if (siblingBorderRadius >= siblingWidth / 2) return [];
|
|
202
|
+
|
|
203
|
+
// Must contain an icon element smaller than the tile
|
|
204
|
+
if (!hasIconChild) return [];
|
|
205
|
+
if (iconChildWidth && iconChildWidth >= siblingWidth * 0.95) return [];
|
|
206
|
+
|
|
207
|
+
// Vertical stacking: tile must end above where the heading starts.
|
|
208
|
+
// (Allow the check to skip when both top/bottom are 0 — jsdom layout case.)
|
|
209
|
+
if (headingTop && siblingBottom && siblingBottom > headingTop + 4) return [];
|
|
210
|
+
|
|
211
|
+
const text = (headingText || '').trim().slice(0, 60);
|
|
212
|
+
return [{
|
|
213
|
+
id: 'icon-tile-stack',
|
|
214
|
+
snippet: `${Math.round(siblingWidth)}x${Math.round(siblingHeight)}px icon tile above ${headingTag} "${text}"`,
|
|
215
|
+
}];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resolve the primary (non-generic) face from a font-family string and return
|
|
219
|
+
// whether the resolved primary is serif. Two paths:
|
|
220
|
+
// 1. Primary face is in KNOWN_SERIF_FONTS → serif.
|
|
221
|
+
// 2. Primary face is unknown but the stack ends in the generic `serif`
|
|
222
|
+
// token → treat as serif. Authors who declare `font-family: 'X', serif`
|
|
223
|
+
// almost always have a serif primary; a sans declared with a serif
|
|
224
|
+
// fallback is a code smell, not the common case.
|
|
225
|
+
// Returns { primary, isSerif } so the snippet can name the face.
|
|
226
|
+
function resolveSerif(fontFamily) {
|
|
227
|
+
if (!fontFamily) return { primary: null, isSerif: false };
|
|
228
|
+
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
229
|
+
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
|
|
230
|
+
if (!primary) return { primary: null, isSerif: false };
|
|
231
|
+
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
|
232
|
+
if (tokens.includes('serif')) return { primary, isSerif: true };
|
|
233
|
+
return { primary, isSerif: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function checkItalicSerif(opts) {
|
|
237
|
+
const { tag, fontStyle, fontFamily, fontSize, headingText } = opts;
|
|
238
|
+
if (fontStyle !== 'italic') return [];
|
|
239
|
+
// Anchor the rule on hero-scale text. h1 is the canonical hero element;
|
|
240
|
+
// h2 ≥ 48px catches the cases where the design demotes the visual hero
|
|
241
|
+
// to an h2 but keeps the size.
|
|
242
|
+
if (tag !== 'h1' && !(tag === 'h2' && fontSize >= 48)) return [];
|
|
243
|
+
if (fontSize < 48) return [];
|
|
244
|
+
const { primary, isSerif } = resolveSerif(fontFamily);
|
|
245
|
+
if (!isSerif) return [];
|
|
246
|
+
|
|
247
|
+
const text = (headingText || '').trim().slice(0, 60);
|
|
248
|
+
return [{
|
|
249
|
+
id: 'italic-serif-display',
|
|
250
|
+
snippet: `italic serif ${tag} (${primary || 'serif'}) at ${Math.round(fontSize)}px "${text}"`,
|
|
251
|
+
}];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Color saturation check. Returns true when the color has visible
|
|
255
|
+
// chroma — i.e., it's an "accent color" rather than near-neutral.
|
|
256
|
+
// Handles rgb()/rgba(), #hex, oklch(), and hsl(). var() refs are
|
|
257
|
+
// expected to be pre-resolved by the caller.
|
|
258
|
+
function isAccentColor(cssColor) {
|
|
259
|
+
if (!cssColor) return false;
|
|
260
|
+
const s = String(cssColor).trim();
|
|
261
|
+
// rgb / rgba — direct channel-distance check.
|
|
262
|
+
const rgbStrict = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
|
|
263
|
+
if (rgbStrict) {
|
|
264
|
+
const r = +rgbStrict[1], g = +rgbStrict[2], b = +rgbStrict[3];
|
|
265
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
|
|
266
|
+
}
|
|
267
|
+
// #hex — 3, 4, 6, or 8 digit.
|
|
268
|
+
const hexM = /^#([0-9a-f]{3,8})\b/i.exec(s);
|
|
269
|
+
if (hexM) {
|
|
270
|
+
let h = hexM[1];
|
|
271
|
+
if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('').slice(0, 6);
|
|
272
|
+
else h = h.slice(0, 6);
|
|
273
|
+
if (h.length === 6) {
|
|
274
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
275
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
276
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
277
|
+
return (Math.max(r, g, b) - Math.min(r, g, b)) >= 40;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// oklch(L C H) — chroma C is what matters. Typical neutral grays
|
|
281
|
+
// have C < 0.02; visible accents are 0.05+. CSS minification can
|
|
282
|
+
// collapse spaces between L% and C ("oklch(43%.15 34)"), so we
|
|
283
|
+
// extract all numbers and take the second rather than matching a
|
|
284
|
+
// strict L-then-whitespace-then-C pattern.
|
|
285
|
+
if (/^oklch\(/i.test(s)) {
|
|
286
|
+
const nums = s.match(/\d*\.\d+|\d+/g);
|
|
287
|
+
if (nums && nums.length >= 2) {
|
|
288
|
+
const c = parseFloat(nums[1]);
|
|
289
|
+
return !Number.isNaN(c) && c >= 0.05;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// hsl(H, S%, L%) — saturation > 20% reads as accent.
|
|
293
|
+
const hslM = /hsla?\(\s*[\d.]+\s*,\s*([\d.]+)%/i.exec(s);
|
|
294
|
+
if (hslM) {
|
|
295
|
+
const sat = parseFloat(hslM[1]);
|
|
296
|
+
return !Number.isNaN(sat) && sat >= 20;
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Sibling-relationship rule. Anchor on a hero-scale h1, look at the
|
|
302
|
+
// previousElementSibling, and gate on EITHER the classic tracked-
|
|
303
|
+
// uppercase eyebrow OR the modern accent-colored bold eyebrow.
|
|
304
|
+
function checkHeroEyebrow(opts) {
|
|
305
|
+
const {
|
|
306
|
+
headingTag, headingText,
|
|
307
|
+
siblingTag, siblingText, siblingTextTransform,
|
|
308
|
+
siblingFontSize, siblingLetterSpacing,
|
|
309
|
+
siblingFontWeight, siblingColor,
|
|
310
|
+
} = opts;
|
|
311
|
+
if (headingTag !== 'h1') return [];
|
|
312
|
+
// We previously gated on headingFontSize >= 48 to anchor "hero scale".
|
|
313
|
+
// But modern hero h1s use clamp() / vw / var(--text-*), none of which
|
|
314
|
+
// jsdom can resolve — the computed value comes back as "2em" or
|
|
315
|
+
// "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
|
|
316
|
+
// on virtually every Tailwind v4 / framework build. The other gates
|
|
317
|
+
// (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
|
|
318
|
+
// tracked-caps) are tight enough to avoid false positives on non-
|
|
319
|
+
// hero h1s — a tiny tan label directly above any h1 is the
|
|
320
|
+
// antipattern regardless of how big the h1 ends up.
|
|
321
|
+
if (!siblingTag) return [];
|
|
322
|
+
// An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
|
|
323
|
+
// headings) — never an eyebrow.
|
|
324
|
+
if (HEADING_TAGS.has(siblingTag)) return [];
|
|
325
|
+
|
|
326
|
+
const text = (siblingText || '').trim();
|
|
327
|
+
if (text.length < 2 || text.length > 60) return [];
|
|
328
|
+
if (!(siblingFontSize > 0 && siblingFontSize <= 14)) return [];
|
|
329
|
+
|
|
330
|
+
// Branch A: classic tracked-uppercase eyebrow.
|
|
331
|
+
const isUppercased = siblingTextTransform === 'uppercase'
|
|
332
|
+
|| (/[A-Z]/.test(text) && !/[a-z]/.test(text));
|
|
333
|
+
const isClassicTracked = isUppercased && siblingLetterSpacing >= 1.6;
|
|
334
|
+
|
|
335
|
+
// Branch B: modern accent-bold eyebrow — sentence case, low
|
|
336
|
+
// tracking, but bold + accent-colored. The style choices changed;
|
|
337
|
+
// the pattern is the same kicker-above-headline anti-pattern.
|
|
338
|
+
const weight = Number(siblingFontWeight) || 400;
|
|
339
|
+
const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
|
|
340
|
+
|
|
341
|
+
if (!isClassicTracked && !isAccentBold) return [];
|
|
342
|
+
|
|
343
|
+
const headingTextSnippet = (headingText || '').trim().slice(0, 60);
|
|
344
|
+
const eyebrowSnippet = text.slice(0, 40);
|
|
345
|
+
const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
|
|
346
|
+
return [{
|
|
347
|
+
id: 'hero-eyebrow-chip',
|
|
348
|
+
snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
|
|
349
|
+
}];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function checkRepeatedSectionKickers(opts) {
|
|
353
|
+
const { candidates, minCount = 3 } = opts;
|
|
354
|
+
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
|
|
355
|
+
return candidates.map(candidate => ({
|
|
356
|
+
id: 'repeated-section-kickers',
|
|
357
|
+
snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const LAYOUT_TRANSITION_PROPS = new Set([
|
|
362
|
+
'width', 'height', 'padding', 'margin',
|
|
363
|
+
'max-height', 'max-width', 'min-height', 'min-width',
|
|
364
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
365
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
366
|
+
]);
|
|
367
|
+
|
|
368
|
+
function checkMotion(opts) {
|
|
369
|
+
const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
|
|
370
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
371
|
+
const findings = [];
|
|
372
|
+
|
|
373
|
+
// --- Bounce/elastic easing ---
|
|
374
|
+
if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
|
|
375
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
|
|
376
|
+
}
|
|
377
|
+
if (classList && /\banimate-bounce\b/.test(classList)) {
|
|
378
|
+
findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
|
|
382
|
+
if (timingFunctions) {
|
|
383
|
+
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
|
|
384
|
+
let m;
|
|
385
|
+
while ((m = bezierRe.exec(timingFunctions)) !== null) {
|
|
386
|
+
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
|
|
387
|
+
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
388
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// --- Layout property transition ---
|
|
395
|
+
if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
|
|
396
|
+
const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
|
|
397
|
+
const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
|
|
398
|
+
if (layoutFound.length > 0) {
|
|
399
|
+
findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return findings;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function checkGlow(opts) {
|
|
407
|
+
const { boxShadow, effectiveBg } = opts;
|
|
408
|
+
if (!boxShadow || boxShadow === 'none') return [];
|
|
409
|
+
if (!effectiveBg) return [];
|
|
410
|
+
|
|
411
|
+
// Only flag on dark backgrounds (luminance < 0.1)
|
|
412
|
+
const bgLum = relativeLuminance(effectiveBg);
|
|
413
|
+
if (bgLum >= 0.1) return [];
|
|
414
|
+
|
|
415
|
+
// Split multiple shadows (commas not inside parentheses)
|
|
416
|
+
const parts = boxShadow.split(/,(?![^(]*\))/);
|
|
417
|
+
for (const shadow of parts) {
|
|
418
|
+
const colorMatch = shadow.match(/rgba?\([^)]+\)/);
|
|
419
|
+
if (!colorMatch) continue;
|
|
420
|
+
const color = parseRgb(colorMatch[0]);
|
|
421
|
+
if (!color || !hasChroma(color, 30)) continue;
|
|
422
|
+
|
|
423
|
+
// Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
|
|
424
|
+
const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
|
|
425
|
+
const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
|
|
426
|
+
const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
|
|
427
|
+
.map(m => parseFloat(m[1]));
|
|
428
|
+
|
|
429
|
+
// Third value is blur (offset-x, offset-y, blur, [spread])
|
|
430
|
+
if (pxVals.length >= 3 && pxVals[2] > 4) {
|
|
431
|
+
return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export {
|
|
439
|
+
DETECTOR_IS_BROWSER,
|
|
440
|
+
checkBorders,
|
|
441
|
+
isEmojiOnlyText,
|
|
442
|
+
checkColors,
|
|
443
|
+
isCardLikeFromProps,
|
|
444
|
+
HEADING_TAGS,
|
|
445
|
+
checkIconTile,
|
|
446
|
+
resolveSerif,
|
|
447
|
+
checkItalicSerif,
|
|
448
|
+
isAccentColor,
|
|
449
|
+
checkHeroEyebrow,
|
|
450
|
+
checkRepeatedSectionKickers,
|
|
451
|
+
checkMotion,
|
|
452
|
+
checkGlow,
|
|
453
|
+
};
|