@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,263 @@
|
|
|
1
|
+
import { resolveLengthPx } from './checks-quality.mjs';
|
|
2
|
+
|
|
3
|
+
// ─── Clipped overflow container ───────────────────────────────────────────────
|
|
4
|
+
// A clipping container (overflow hidden/clip, not a scroll region) wrapping an
|
|
5
|
+
// absolutely/fixed-positioned descendant clips popovers/menus that must escape.
|
|
6
|
+
function classSelector(el) {
|
|
7
|
+
const cls = (el.getAttribute ? el.getAttribute('class') : el.className) || '';
|
|
8
|
+
const tokens = String(cls).trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
const tag = el.tagName ? el.tagName.toLowerCase() : 'el';
|
|
10
|
+
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function positionedChildIsDecorative(child) {
|
|
14
|
+
if (!child || typeof child.getAttribute !== 'function') return false;
|
|
15
|
+
if (child.closest?.('[aria-hidden="true"]')) return true;
|
|
16
|
+
const role = (child.getAttribute('role') || '').toLowerCase();
|
|
17
|
+
if (role === 'none' || role === 'presentation') return true;
|
|
18
|
+
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
|
19
|
+
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
|
20
|
+
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
|
21
|
+
if (
|
|
22
|
+
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
|
23
|
+
!positionedChildHasSubstantiveContent(child)
|
|
24
|
+
) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
|
31
|
+
'a[href]',
|
|
32
|
+
'button',
|
|
33
|
+
'input',
|
|
34
|
+
'select',
|
|
35
|
+
'summary',
|
|
36
|
+
'textarea',
|
|
37
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
38
|
+
'[role="button"]',
|
|
39
|
+
'[role="dialog"]',
|
|
40
|
+
'[role="link"]',
|
|
41
|
+
'[role="listbox"]',
|
|
42
|
+
'[role="menu"]',
|
|
43
|
+
'[role="menuitem"]',
|
|
44
|
+
'[role="option"]',
|
|
45
|
+
'[role="tooltip"]',
|
|
46
|
+
].join(',');
|
|
47
|
+
|
|
48
|
+
function positionedChildHasSubstantiveContent(child) {
|
|
49
|
+
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
|
50
|
+
if (text.length > 0) return true;
|
|
51
|
+
if (typeof child.matches === 'function') {
|
|
52
|
+
try {
|
|
53
|
+
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
if (typeof child.querySelector === 'function') {
|
|
57
|
+
try {
|
|
58
|
+
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clippingContainerIsIntentionalViewport(el) {
|
|
65
|
+
if (!el || typeof el.getAttribute !== 'function') return false;
|
|
66
|
+
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
|
67
|
+
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
|
68
|
+
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
|
69
|
+
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
|
70
|
+
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function elementRect(el) {
|
|
74
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
|
75
|
+
try {
|
|
76
|
+
const rect = el.getBoundingClientRect();
|
|
77
|
+
if (!rect) return null;
|
|
78
|
+
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
|
79
|
+
if (!values.every(Number.isFinite)) return null;
|
|
80
|
+
if (rect.width <= 0 && rect.height <= 0) return null;
|
|
81
|
+
return rect;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function positionedStyleImpliesEscape(style) {
|
|
88
|
+
const values = [
|
|
89
|
+
style.top,
|
|
90
|
+
style.right,
|
|
91
|
+
style.bottom,
|
|
92
|
+
style.left,
|
|
93
|
+
style.inset,
|
|
94
|
+
style.insetBlock,
|
|
95
|
+
style.insetInline,
|
|
96
|
+
style.insetBlockStart,
|
|
97
|
+
style.insetBlockEnd,
|
|
98
|
+
style.insetInlineStart,
|
|
99
|
+
style.insetInlineEnd,
|
|
100
|
+
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
|
101
|
+
for (const value of values) {
|
|
102
|
+
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
|
103
|
+
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
|
109
|
+
const parentRect = elementRect(el);
|
|
110
|
+
const childRect = elementRect(child);
|
|
111
|
+
if (!parentRect || !childRect) return null;
|
|
112
|
+
const threshold = 2;
|
|
113
|
+
return Boolean(
|
|
114
|
+
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
|
115
|
+
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkClippedOverflow(el, style, getStyle) {
|
|
120
|
+
const clips = (v) => v === 'hidden' || v === 'clip';
|
|
121
|
+
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
|
122
|
+
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
|
123
|
+
const clipX = clips(ox) || clips(ov);
|
|
124
|
+
const clipY = clips(oy) || clips(ov);
|
|
125
|
+
const anyClip = clipX || clipY;
|
|
126
|
+
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
|
127
|
+
if (!anyClip || anyScroll) return [];
|
|
128
|
+
if (clippingContainerIsIntentionalViewport(el)) return [];
|
|
129
|
+
if (!el.querySelectorAll) return [];
|
|
130
|
+
for (const child of el.querySelectorAll('*')) {
|
|
131
|
+
const childStyle = getStyle(child);
|
|
132
|
+
const pos = childStyle.position || '';
|
|
133
|
+
if (pos === 'absolute' || pos === 'fixed') {
|
|
134
|
+
if (positionedChildIsDecorative(child)) continue;
|
|
135
|
+
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
|
136
|
+
if (escapes === false) continue;
|
|
137
|
+
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
|
138
|
+
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function checkElementClippedOverflow(el, style, _tag, window) {
|
|
145
|
+
return checkClippedOverflow(el, style, (n) => window.getComputedStyle(n));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkElementClippedOverflowDOM(el) {
|
|
149
|
+
const style = getComputedStyle(el);
|
|
150
|
+
return checkClippedOverflow(el, style, (n) => getComputedStyle(n));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
|
154
|
+
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
|
155
|
+
|
|
156
|
+
function metricLengthPx(value, fontSizePx = 16) {
|
|
157
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
158
|
+
if (typeof value !== 'string') return null;
|
|
159
|
+
return resolveLengthPx(value, fontSizePx);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function firstMetricLengthPx(fontSizePx, ...values) {
|
|
163
|
+
for (const value of values) {
|
|
164
|
+
const parsed = metricLengthPx(value, fontSizePx);
|
|
165
|
+
if (parsed !== null) return parsed;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function expandBoxShorthand(parts) {
|
|
171
|
+
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
|
172
|
+
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
|
173
|
+
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
|
174
|
+
return [parts[0], parts[1], parts[2], parts[3]];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clippedByInset(clipPath) {
|
|
178
|
+
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
|
179
|
+
if (!match) return false;
|
|
180
|
+
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
|
181
|
+
if (!beforeRound) return false;
|
|
182
|
+
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
|
183
|
+
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
|
184
|
+
if (percents.some(match => !match)) return false;
|
|
185
|
+
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
|
186
|
+
return top + bottom >= 100 || left + right >= 100;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clippedByRect(clip) {
|
|
190
|
+
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
|
191
|
+
if (!match) return false;
|
|
192
|
+
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
|
193
|
+
if (values.length !== 4) return false;
|
|
194
|
+
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
|
195
|
+
if ([top, right, bottom, left].some(value => value === null)) return false;
|
|
196
|
+
return bottom <= top || right <= left;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
|
200
|
+
if (!style) return false;
|
|
201
|
+
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
|
202
|
+
.map(value => String(value || '').toLowerCase());
|
|
203
|
+
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
|
204
|
+
|
|
205
|
+
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
|
206
|
+
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
|
207
|
+
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
|
208
|
+
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
|
209
|
+
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
|
210
|
+
|
|
211
|
+
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
|
212
|
+
const clip = String(style.clip || '').trim();
|
|
213
|
+
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isRenderedForBrowserRule(el) {
|
|
217
|
+
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
|
218
|
+
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
|
219
|
+
const style = getComputedStyle(cur);
|
|
220
|
+
const visibility = String(style.visibility || '').toLowerCase();
|
|
221
|
+
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
|
222
|
+
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
|
223
|
+
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function checkElementTextOverflowDOM(el) {
|
|
229
|
+
const tag = el.tagName.toLowerCase();
|
|
230
|
+
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
|
231
|
+
if (!isRenderedForBrowserRule(el)) return [];
|
|
232
|
+
// Only the element that actually owns overflowing text — not its ancestors,
|
|
233
|
+
// which inherit a wider scrollWidth from the spilling descendant.
|
|
234
|
+
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
|
235
|
+
if (!hasDirectText) return [];
|
|
236
|
+
const style = getComputedStyle(el);
|
|
237
|
+
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
238
|
+
if (isScreenReaderOnlyTextStyle(style, {
|
|
239
|
+
width: rect?.width,
|
|
240
|
+
height: rect?.height,
|
|
241
|
+
clientWidth: el.clientWidth,
|
|
242
|
+
clientHeight: el.clientHeight,
|
|
243
|
+
})) return [];
|
|
244
|
+
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
|
245
|
+
if (isScrollRegion(style)) return [];
|
|
246
|
+
// A scrollable ancestor means this overflow is intentional and scrollable.
|
|
247
|
+
for (let p = el.parentElement; p; p = p.parentElement) {
|
|
248
|
+
if (isScrollRegion(getComputedStyle(p))) return [];
|
|
249
|
+
}
|
|
250
|
+
const delta = el.scrollWidth - el.clientWidth;
|
|
251
|
+
if (el.clientWidth > 0 && delta >= 16) {
|
|
252
|
+
return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export {
|
|
258
|
+
checkClippedOverflow,
|
|
259
|
+
checkElementClippedOverflow,
|
|
260
|
+
checkElementClippedOverflowDOM,
|
|
261
|
+
isScreenReaderOnlyTextStyle,
|
|
262
|
+
checkElementTextOverflowDOM,
|
|
263
|
+
};
|