@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.
@@ -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
+ };