@monoes/monodesign 1.2.18 → 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/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,137 @@
|
|
|
1
|
+
function buildCustomPropMap(document) {
|
|
2
|
+
const map = new Map();
|
|
3
|
+
let sheets;
|
|
4
|
+
try { sheets = Array.from(document.styleSheets || []); }
|
|
5
|
+
catch { return map; }
|
|
6
|
+
for (const sheet of sheets) {
|
|
7
|
+
let rules;
|
|
8
|
+
try { rules = Array.from(sheet.cssRules || []); }
|
|
9
|
+
catch { continue; }
|
|
10
|
+
for (const rule of rules) {
|
|
11
|
+
// Style rules only (type 1). Walk @media / @supports if present.
|
|
12
|
+
if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
|
|
13
|
+
try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (rule.type !== 1 /* STYLE_RULE */) continue;
|
|
17
|
+
const sel = rule.selectorText || '';
|
|
18
|
+
if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
|
|
19
|
+
const style = rule.style;
|
|
20
|
+
if (!style) continue;
|
|
21
|
+
for (let i = 0; i < style.length; i++) {
|
|
22
|
+
const prop = style[i];
|
|
23
|
+
if (!prop?.startsWith('--')) continue;
|
|
24
|
+
const val = style.getPropertyValue(prop).trim();
|
|
25
|
+
if (val) map.set(prop, val);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve var(--X[, fallback]) refs in a computed-style value string.
|
|
33
|
+
// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
|
|
34
|
+
// the original string when no refs are present or the chain doesn't
|
|
35
|
+
// resolve. Safe to call on already-resolved values.
|
|
36
|
+
function resolveVarRefs(raw, customPropMap, depth = 0) {
|
|
37
|
+
if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
|
|
38
|
+
if (depth > 8) return raw;
|
|
39
|
+
return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
|
|
40
|
+
const v = customPropMap.get(name);
|
|
41
|
+
if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
|
|
42
|
+
return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
|
|
47
|
+
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
|
|
48
|
+
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
|
|
49
|
+
// returns the literal "oklch(...)" string. Without this, the entire
|
|
50
|
+
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
|
|
51
|
+
// detector's contrast / color checks.
|
|
52
|
+
function oklchToRgb(L, C, H) {
|
|
53
|
+
const hRad = (H * Math.PI) / 180;
|
|
54
|
+
const a = C * Math.cos(hRad);
|
|
55
|
+
const b = C * Math.sin(hRad);
|
|
56
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
57
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
58
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
59
|
+
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
|
60
|
+
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
|
61
|
+
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
|
62
|
+
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
|
|
63
|
+
const enc = (x) => {
|
|
64
|
+
const c = Math.max(0, Math.min(1, x));
|
|
65
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
r: Math.round(enc(rLin) * 255),
|
|
69
|
+
g: Math.round(enc(gLin) * 255),
|
|
70
|
+
b: Math.round(enc(bLin) * 255),
|
|
71
|
+
a: 1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
|
|
76
|
+
// Use this when the input might be any CSS color form; use plain parseRgb
|
|
77
|
+
// when you only expect computed rgb() values from real browsers.
|
|
78
|
+
function parseAnyColor(s) {
|
|
79
|
+
if (!s || typeof s !== 'string') return null;
|
|
80
|
+
const str = s.trim();
|
|
81
|
+
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
|
82
|
+
let m;
|
|
83
|
+
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
|
|
84
|
+
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
|
|
85
|
+
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
|
86
|
+
if (m) {
|
|
87
|
+
const h = m[1];
|
|
88
|
+
if (h.length === 3 || h.length === 4) {
|
|
89
|
+
return {
|
|
90
|
+
r: parseInt(h[0] + h[0], 16),
|
|
91
|
+
g: parseInt(h[1] + h[1], 16),
|
|
92
|
+
b: parseInt(h[2] + h[2], 16),
|
|
93
|
+
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (h.length === 6 || h.length === 8) {
|
|
97
|
+
return {
|
|
98
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
99
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
100
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
101
|
+
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
106
|
+
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
107
|
+
// Match L (with optional %), then C and H separated permissively.
|
|
108
|
+
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
109
|
+
if (m) {
|
|
110
|
+
const Lnum = parseFloat(m[1]);
|
|
111
|
+
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
112
|
+
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
113
|
+
if (m[5] !== undefined) {
|
|
114
|
+
const alpha = parseFloat(m[5]);
|
|
115
|
+
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
116
|
+
}
|
|
117
|
+
return rgb;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Resolve var() refs in a color string (via customPropMap), then parse.
|
|
123
|
+
// Returns null on any failure. Used in jsdom-mode paths where
|
|
124
|
+
// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
|
|
125
|
+
function parseColorResolved(str, customPropMap) {
|
|
126
|
+
if (!str) return null;
|
|
127
|
+
const resolved = customPropMap ? resolveVarRefs(str, customPropMap) : str;
|
|
128
|
+
return parseAnyColor(resolved);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
buildCustomPropMap,
|
|
133
|
+
resolveVarRefs,
|
|
134
|
+
oklchToRgb,
|
|
135
|
+
parseAnyColor,
|
|
136
|
+
parseColorResolved,
|
|
137
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { SAFE_TAGS } from '../shared/constants.mjs';
|
|
2
|
+
import {
|
|
3
|
+
getHue,
|
|
4
|
+
hasChroma,
|
|
5
|
+
parseGradientColors,
|
|
6
|
+
parseRgb,
|
|
7
|
+
relativeLuminance,
|
|
8
|
+
} from '../shared/color.mjs';
|
|
9
|
+
import {
|
|
10
|
+
HEADING_TAGS,
|
|
11
|
+
checkRepeatedSectionKickers,
|
|
12
|
+
checkMotion,
|
|
13
|
+
checkGlow,
|
|
14
|
+
} from './checks-pure.mjs';
|
|
15
|
+
import { resolveBackground } from './checks-background.mjs';
|
|
16
|
+
import { resolveLengthPx } from './checks-quality.mjs';
|
|
17
|
+
|
|
18
|
+
const REPEATED_KICKER_SKIP_SELECTOR = [
|
|
19
|
+
'nav',
|
|
20
|
+
'form',
|
|
21
|
+
'table',
|
|
22
|
+
'thead',
|
|
23
|
+
'tbody',
|
|
24
|
+
'tfoot',
|
|
25
|
+
'figure',
|
|
26
|
+
'figcaption',
|
|
27
|
+
'ol',
|
|
28
|
+
'ul',
|
|
29
|
+
'li',
|
|
30
|
+
'[role="navigation"]',
|
|
31
|
+
'[aria-label*="breadcrumb" i]',
|
|
32
|
+
'[class*="breadcrumb" i]',
|
|
33
|
+
'[aria-hidden="true"]',
|
|
34
|
+
'[data-monodesign-allow-kickers]',
|
|
35
|
+
].join(',');
|
|
36
|
+
|
|
37
|
+
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
|
38
|
+
'article',
|
|
39
|
+
'button',
|
|
40
|
+
'a',
|
|
41
|
+
'li',
|
|
42
|
+
'[role="listitem"]',
|
|
43
|
+
'[role="option"]',
|
|
44
|
+
].join(',');
|
|
45
|
+
|
|
46
|
+
function cleanInlineText(el) {
|
|
47
|
+
return [...el.childNodes]
|
|
48
|
+
.filter(n => n.nodeType === 3)
|
|
49
|
+
.map(n => n.textContent)
|
|
50
|
+
.join(' ')
|
|
51
|
+
.replace(/\s+/g, ' ')
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isRepeatedKickerCardContext(heading, kicker) {
|
|
56
|
+
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
|
57
|
+
return Boolean(item && (!item.contains || item.contains(kicker)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRepeatedKickerCandidate(opts) {
|
|
61
|
+
const {
|
|
62
|
+
headingTag,
|
|
63
|
+
headingText,
|
|
64
|
+
headingFontSize,
|
|
65
|
+
kickerTag,
|
|
66
|
+
kickerText,
|
|
67
|
+
kickerTextTransform,
|
|
68
|
+
kickerFontSize,
|
|
69
|
+
kickerLetterSpacing,
|
|
70
|
+
} = opts;
|
|
71
|
+
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
|
72
|
+
if (!headingText || headingText.length < 3) return false;
|
|
73
|
+
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
|
74
|
+
if (!(headingFontSize >= 20)) return false;
|
|
75
|
+
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
|
76
|
+
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
|
77
|
+
if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
|
|
78
|
+
if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
|
|
79
|
+
|
|
80
|
+
const isUppercased = kickerTextTransform === 'uppercase'
|
|
81
|
+
|| (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
|
|
82
|
+
if (!isUppercased) return false;
|
|
83
|
+
if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
|
|
84
|
+
const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
|
|
85
|
+
if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
|
|
90
|
+
const candidates = [];
|
|
91
|
+
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
|
|
92
|
+
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
93
|
+
const kicker = heading.previousElementSibling;
|
|
94
|
+
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
95
|
+
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
|
96
|
+
|
|
97
|
+
const headingStyle = getStyle(heading);
|
|
98
|
+
const kickerStyle = getStyle(kicker);
|
|
99
|
+
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
|
|
100
|
+
const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
|
|
101
|
+
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
|
|
102
|
+
const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
|
|
103
|
+
const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
|
|
104
|
+
|
|
105
|
+
if (!isRepeatedKickerCandidate({
|
|
106
|
+
headingTag: heading.tagName.toLowerCase(),
|
|
107
|
+
headingText,
|
|
108
|
+
headingFontSize,
|
|
109
|
+
kickerTag: kicker.tagName.toLowerCase(),
|
|
110
|
+
kickerText,
|
|
111
|
+
kickerTextTransform: kickerStyle.textTransform || '',
|
|
112
|
+
kickerFontSize,
|
|
113
|
+
kickerLetterSpacing,
|
|
114
|
+
})) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
candidates.push({
|
|
119
|
+
headingTag: heading.tagName.toLowerCase(),
|
|
120
|
+
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
|
|
121
|
+
kickerText: kickerText.slice(0, 40),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return candidates;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkRepeatedSectionKickersDOM() {
|
|
128
|
+
const candidates = collectRepeatedSectionKickerCandidates(
|
|
129
|
+
document,
|
|
130
|
+
(el) => getComputedStyle(el),
|
|
131
|
+
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
|
132
|
+
);
|
|
133
|
+
return checkRepeatedSectionKickers({ candidates });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function checkElementMotionDOM(el) {
|
|
137
|
+
const tag = el.tagName.toLowerCase();
|
|
138
|
+
if (SAFE_TAGS.has(tag)) return [];
|
|
139
|
+
const style = getComputedStyle(el);
|
|
140
|
+
return checkMotion({
|
|
141
|
+
tag,
|
|
142
|
+
transitionProperty: style.transitionProperty || '',
|
|
143
|
+
animationName: style.animationName || '',
|
|
144
|
+
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
|
|
145
|
+
classList: el.getAttribute('class') || '',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkElementGlowDOM(el) {
|
|
150
|
+
const tag = el.tagName.toLowerCase();
|
|
151
|
+
const style = getComputedStyle(el);
|
|
152
|
+
if (!style.boxShadow || style.boxShadow === 'none') return [];
|
|
153
|
+
// Use parent's background — glow radiates outward, so the surrounding context matters
|
|
154
|
+
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
|
155
|
+
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
|
|
156
|
+
if (!parentBg) {
|
|
157
|
+
// Gradient background — sample its colors to determine if it's dark
|
|
158
|
+
let cur = el.parentElement;
|
|
159
|
+
while (cur && cur.nodeType === 1) {
|
|
160
|
+
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
|
161
|
+
const gradColors = parseGradientColors(bgImage);
|
|
162
|
+
if (gradColors.length > 0) {
|
|
163
|
+
// Average the gradient colors
|
|
164
|
+
const avg = { r: 0, g: 0, b: 0 };
|
|
165
|
+
for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
|
|
166
|
+
avg.r = Math.round(avg.r / gradColors.length);
|
|
167
|
+
avg.g = Math.round(avg.g / gradColors.length);
|
|
168
|
+
avg.b = Math.round(avg.b / gradColors.length);
|
|
169
|
+
parentBg = avg;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
cur = cur.parentElement;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function checkElementAIPaletteDOM(el) {
|
|
179
|
+
const style = getComputedStyle(el);
|
|
180
|
+
const findings = [];
|
|
181
|
+
|
|
182
|
+
// Check gradient backgrounds for purple/violet or cyan
|
|
183
|
+
const bgImage = style.backgroundImage || '';
|
|
184
|
+
const gradColors = parseGradientColors(bgImage);
|
|
185
|
+
for (const c of gradColors) {
|
|
186
|
+
if (hasChroma(c, 50)) {
|
|
187
|
+
const hue = getHue(c);
|
|
188
|
+
if (hue >= 260 && hue <= 310) {
|
|
189
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (hue >= 160 && hue <= 200) {
|
|
193
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Check for neon text (vivid cyan/purple color on dark background)
|
|
200
|
+
const textColor = parseRgb(style.color);
|
|
201
|
+
if (textColor && hasChroma(textColor, 80)) {
|
|
202
|
+
const hue = getHue(textColor);
|
|
203
|
+
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
|
204
|
+
if (isAIPalette) {
|
|
205
|
+
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
|
|
206
|
+
// Also check gradient parents
|
|
207
|
+
let effectiveBg = parentBg;
|
|
208
|
+
if (!effectiveBg) {
|
|
209
|
+
let cur = el.parentElement;
|
|
210
|
+
while (cur && cur.nodeType === 1) {
|
|
211
|
+
const gi = getComputedStyle(cur).backgroundImage || '';
|
|
212
|
+
const gc = parseGradientColors(gi);
|
|
213
|
+
if (gc.length > 0) {
|
|
214
|
+
const avg = { r: 0, g: 0, b: 0 };
|
|
215
|
+
for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
|
|
216
|
+
avg.r = Math.round(avg.r / gc.length);
|
|
217
|
+
avg.g = Math.round(avg.g / gc.length);
|
|
218
|
+
avg.b = Math.round(avg.b / gc.length);
|
|
219
|
+
effectiveBg = avg;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
cur = cur.parentElement;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
|
|
226
|
+
const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
|
|
227
|
+
findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return findings;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export {
|
|
236
|
+
cleanInlineText,
|
|
237
|
+
isRepeatedKickerCandidate,
|
|
238
|
+
collectRepeatedSectionKickerCandidates,
|
|
239
|
+
checkRepeatedSectionKickersDOM,
|
|
240
|
+
checkElementMotionDOM,
|
|
241
|
+
checkElementGlowDOM,
|
|
242
|
+
checkElementAIPaletteDOM,
|
|
243
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { BORDER_SAFE_TAGS } from '../shared/constants.mjs';
|
|
2
|
+
import { parseRgb } from '../shared/color.mjs';
|
|
3
|
+
import {
|
|
4
|
+
checkBorders,
|
|
5
|
+
isEmojiOnlyText,
|
|
6
|
+
checkColors,
|
|
7
|
+
HEADING_TAGS,
|
|
8
|
+
checkIconTile,
|
|
9
|
+
checkItalicSerif,
|
|
10
|
+
checkHeroEyebrow,
|
|
11
|
+
} from './checks-pure.mjs';
|
|
12
|
+
import {
|
|
13
|
+
readOwnBackgroundColor,
|
|
14
|
+
resolveBackground,
|
|
15
|
+
resolveGradientStops,
|
|
16
|
+
} from './checks-background.mjs';
|
|
17
|
+
|
|
18
|
+
// ─── Section 5: Element Adapters ────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
// Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
|
|
21
|
+
|
|
22
|
+
function checkElementBordersDOM(el) {
|
|
23
|
+
const tag = el.tagName.toLowerCase();
|
|
24
|
+
if (BORDER_SAFE_TAGS.has(tag)) return [];
|
|
25
|
+
const rect = el.getBoundingClientRect();
|
|
26
|
+
if (rect.width < 20 || rect.height < 20) return [];
|
|
27
|
+
const style = getComputedStyle(el);
|
|
28
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
29
|
+
const widths = {}, colors = {};
|
|
30
|
+
for (const s of sides) {
|
|
31
|
+
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
|
|
32
|
+
colors[s] = style[`border${s}Color`] || '';
|
|
33
|
+
}
|
|
34
|
+
return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkElementColorsDOM(el) {
|
|
38
|
+
const tag = el.tagName.toLowerCase();
|
|
39
|
+
// No early SAFE_TAGS bail here — checkColors() does its own gating that
|
|
40
|
+
// includes the styled-button exception for <a> / <button> with their own
|
|
41
|
+
// opaque background. Bailing here would prevent that exception from firing.
|
|
42
|
+
const rect = el.getBoundingClientRect();
|
|
43
|
+
if (rect.width < 10 || rect.height < 10) return [];
|
|
44
|
+
const style = getComputedStyle(el);
|
|
45
|
+
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
46
|
+
const hasDirectText = directText.trim().length > 0;
|
|
47
|
+
const effectiveBg = resolveBackground(el);
|
|
48
|
+
return checkColors({
|
|
49
|
+
tag,
|
|
50
|
+
textColor: parseRgb(style.color),
|
|
51
|
+
bgColor: readOwnBackgroundColor(el, style),
|
|
52
|
+
effectiveBg,
|
|
53
|
+
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
|
54
|
+
fontSize: parseFloat(style.fontSize) || 16,
|
|
55
|
+
fontWeight: parseInt(style.fontWeight, 10) || 400,
|
|
56
|
+
hasDirectText,
|
|
57
|
+
isEmojiOnly: isEmojiOnlyText(directText),
|
|
58
|
+
bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
|
|
59
|
+
bgImage: style.backgroundImage || '',
|
|
60
|
+
classList: el.getAttribute('class') || '',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkElementIconTileDOM(el) {
|
|
65
|
+
const tag = el.tagName.toLowerCase();
|
|
66
|
+
if (!HEADING_TAGS.has(tag)) return [];
|
|
67
|
+
const sibling = el.previousElementSibling;
|
|
68
|
+
if (!sibling) return [];
|
|
69
|
+
|
|
70
|
+
const sibRect = sibling.getBoundingClientRect();
|
|
71
|
+
const headRect = el.getBoundingClientRect();
|
|
72
|
+
const sibStyle = getComputedStyle(sibling);
|
|
73
|
+
|
|
74
|
+
// The tile may either contain an <svg>/<i> icon child, OR the tile itself
|
|
75
|
+
// may contain an emoji/symbol character directly as its only text content
|
|
76
|
+
// (the "card-icon" pattern from many AI-generated demos).
|
|
77
|
+
const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
|
|
78
|
+
const iconRect = iconChild?.getBoundingClientRect();
|
|
79
|
+
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
80
|
+
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
|
|
81
|
+
|
|
82
|
+
return checkIconTile({
|
|
83
|
+
headingTag: tag,
|
|
84
|
+
headingText: el.textContent || '',
|
|
85
|
+
headingTop: headRect.top,
|
|
86
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
87
|
+
siblingWidth: sibRect.width,
|
|
88
|
+
siblingHeight: sibRect.height,
|
|
89
|
+
siblingBottom: sibRect.bottom,
|
|
90
|
+
siblingBgColor: parseRgb(sibStyle.backgroundColor),
|
|
91
|
+
siblingBgImage: sibStyle.backgroundImage || '',
|
|
92
|
+
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
|
|
93
|
+
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
|
|
94
|
+
hasIconChild: !!iconChild || hasInlineEmojiIcon,
|
|
95
|
+
iconChildWidth: iconRect?.width || 0,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkElementItalicSerifDOM(el) {
|
|
100
|
+
const tag = el.tagName.toLowerCase();
|
|
101
|
+
if (tag !== 'h1' && tag !== 'h2') return [];
|
|
102
|
+
const style = getComputedStyle(el);
|
|
103
|
+
return checkItalicSerif({
|
|
104
|
+
tag,
|
|
105
|
+
fontStyle: style.fontStyle || '',
|
|
106
|
+
fontFamily: style.fontFamily || '',
|
|
107
|
+
fontSize: parseFloat(style.fontSize) || 0,
|
|
108
|
+
headingText: el.textContent || '',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function checkElementHeroEyebrowDOM(el) {
|
|
113
|
+
const tag = el.tagName.toLowerCase();
|
|
114
|
+
if (tag !== 'h1') return [];
|
|
115
|
+
const sibling = el.previousElementSibling;
|
|
116
|
+
if (!sibling) return [];
|
|
117
|
+
const headStyle = getComputedStyle(el);
|
|
118
|
+
const sibStyle = getComputedStyle(sibling);
|
|
119
|
+
return checkHeroEyebrow({
|
|
120
|
+
headingTag: tag,
|
|
121
|
+
headingText: el.textContent || '',
|
|
122
|
+
headingFontSize: parseFloat(headStyle.fontSize) || 0,
|
|
123
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
124
|
+
siblingText: sibling.textContent || '',
|
|
125
|
+
siblingTextTransform: sibStyle.textTransform || '',
|
|
126
|
+
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
|
127
|
+
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
|
|
128
|
+
siblingFontWeight: sibStyle.fontWeight || '',
|
|
129
|
+
siblingColor: sibStyle.color || '',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build a map of CSS custom properties declared on :root / :host / html.
|
|
134
|
+
// Used to resolve var(--X) refs that jsdom returns verbatim in
|
|
135
|
+
// getComputedStyle. Tailwind v4 routes every utility class through
|
|
136
|
+
// CSS vars (font-weight: var(--font-weight-bold), font-size:
|
|
137
|
+
// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
|
|
138
|
+
// resolution every style-based check silently fails on Tailwind v4
|
|
139
|
+
// builds — the values come back as literal "var(--font-weight-bold)"
|
|
140
|
+
// strings and parseFloat returns NaN.
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
checkElementBordersDOM,
|
|
144
|
+
checkElementColorsDOM,
|
|
145
|
+
checkElementIconTileDOM,
|
|
146
|
+
checkElementItalicSerifDOM,
|
|
147
|
+
checkElementHeroEyebrowDOM,
|
|
148
|
+
};
|