@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,447 @@
|
|
|
1
|
+
import { parseAnyColor } from './checks-css-values.mjs';
|
|
2
|
+
|
|
3
|
+
const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
|
|
4
|
+
|
|
5
|
+
// Resolve a CSS font-size value to pixels by walking up the parent chain.
|
|
6
|
+
// Browsers resolve em/rem/% to px in getComputedStyle, but jsdom returns the
|
|
7
|
+
// specified value verbatim — so for the Node path we walk parents ourselves.
|
|
8
|
+
function resolveFontSizePx(el, win) {
|
|
9
|
+
const chain = []; // raw font-size strings, leaf → root
|
|
10
|
+
let cur = el;
|
|
11
|
+
while (cur && cur.nodeType === 1) {
|
|
12
|
+
const fs = (win ? win.getComputedStyle(cur) : getComputedStyle(cur)).fontSize;
|
|
13
|
+
chain.push(fs || '');
|
|
14
|
+
cur = cur.parentElement;
|
|
15
|
+
}
|
|
16
|
+
// Walk root → leaf, resolving each value relative to its parent context.
|
|
17
|
+
let px = 16; // root default
|
|
18
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
19
|
+
const v = chain[i];
|
|
20
|
+
if (!v || v === 'inherit') continue;
|
|
21
|
+
const num = parseFloat(v);
|
|
22
|
+
if (Number.isNaN(num)) continue;
|
|
23
|
+
if (v.endsWith('px')) px = num;
|
|
24
|
+
else if (v.endsWith('rem')) px = num * 16;
|
|
25
|
+
else if (v.endsWith('em')) px = num * px;
|
|
26
|
+
else if (v.endsWith('%')) px = (num / 100) * px;
|
|
27
|
+
else px = num; // unitless — already resolved
|
|
28
|
+
}
|
|
29
|
+
return px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve a CSS length value (line-height, letter-spacing, etc.) given a
|
|
33
|
+
// known font-size context. Returns null for "normal" / unparseable values.
|
|
34
|
+
function resolveLengthPx(value, fontSizePx) {
|
|
35
|
+
if (!value || value === 'normal' || value === 'auto' || value === 'inherit') return null;
|
|
36
|
+
const num = parseFloat(value);
|
|
37
|
+
if (Number.isNaN(num)) return null;
|
|
38
|
+
if (value.endsWith('px')) return num;
|
|
39
|
+
if (value.endsWith('rem')) return num * 16;
|
|
40
|
+
if (value.endsWith('em')) return num * fontSizePx;
|
|
41
|
+
if (value.endsWith('%')) return (num / 100) * fontSizePx;
|
|
42
|
+
// Unitless line-height = multiplier, return px equivalent
|
|
43
|
+
return num * fontSizePx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cssColorIsTransparent(value) {
|
|
47
|
+
if (!value) return true;
|
|
48
|
+
const str = String(value).trim().toLowerCase();
|
|
49
|
+
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
|
50
|
+
const parsed = parseAnyColor(str);
|
|
51
|
+
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
|
52
|
+
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function colorsNearlyMatch(a, b) {
|
|
56
|
+
const ca = parseAnyColor(a);
|
|
57
|
+
const cb = parseAnyColor(b);
|
|
58
|
+
if (!ca || !cb) return false;
|
|
59
|
+
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
|
60
|
+
const channelDelta = Math.max(
|
|
61
|
+
Math.abs(ca.r - cb.r),
|
|
62
|
+
Math.abs(ca.g - cb.g),
|
|
63
|
+
Math.abs(ca.b - cb.b),
|
|
64
|
+
);
|
|
65
|
+
return alphaDelta <= 0.03 && channelDelta <= 3;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getComputedStyleFor(win, el) {
|
|
69
|
+
if (win && typeof win.getComputedStyle === 'function') {
|
|
70
|
+
try { return win.getComputedStyle(el); } catch {}
|
|
71
|
+
}
|
|
72
|
+
if (typeof getComputedStyle === 'function') {
|
|
73
|
+
try { return getComputedStyle(el); } catch {}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasVisibleBackgroundBoundary(style, el, win) {
|
|
79
|
+
const bg = style?.backgroundColor || '';
|
|
80
|
+
if (cssColorIsTransparent(bg)) return false;
|
|
81
|
+
|
|
82
|
+
let parent = el?.parentElement || null;
|
|
83
|
+
while (parent) {
|
|
84
|
+
const parentStyle = getComputedStyleFor(win, parent);
|
|
85
|
+
const parentBg = parentStyle?.backgroundColor || '';
|
|
86
|
+
if (!cssColorIsTransparent(parentBg)) {
|
|
87
|
+
return !colorsNearlyMatch(bg, parentBg);
|
|
88
|
+
}
|
|
89
|
+
parent = parent.parentElement;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
|
96
|
+
|
|
97
|
+
function hasMeaningfulDirectText(node) {
|
|
98
|
+
if (!node?.childNodes) return false;
|
|
99
|
+
for (const child of node.childNodes) {
|
|
100
|
+
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function textDescendantsFlushSides(el, rect) {
|
|
106
|
+
const flush = { top: false, right: false, bottom: false, left: false };
|
|
107
|
+
if (!rect || !el?.querySelectorAll) return flush;
|
|
108
|
+
const TEXT_EDGE_THRESHOLD = 4;
|
|
109
|
+
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
|
110
|
+
for (const node of candidates) {
|
|
111
|
+
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
|
112
|
+
let nodeRect = null;
|
|
113
|
+
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
|
114
|
+
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
|
115
|
+
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
|
116
|
+
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
|
117
|
+
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
|
118
|
+
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
|
119
|
+
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
|
120
|
+
}
|
|
121
|
+
return flush;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
|
125
|
+
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
|
126
|
+
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
|
127
|
+
// the Node adapter to skip those.
|
|
128
|
+
//
|
|
129
|
+
// Both adapters resolve font-size, line-height and letter-spacing to pixels
|
|
130
|
+
// before calling this so the pure function only deals with numbers.
|
|
131
|
+
function checkQuality(opts) {
|
|
132
|
+
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
|
133
|
+
const findings = [];
|
|
134
|
+
// Skip browser extension injected elements
|
|
135
|
+
const elId = el.id || '';
|
|
136
|
+
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
|
137
|
+
|
|
138
|
+
// --- Line length too long --- (browser-only: needs rect.width)
|
|
139
|
+
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
|
140
|
+
const charsPerLine = rect.width / (fontSize * 0.5);
|
|
141
|
+
if (charsPerLine > lineMax + 5) {
|
|
142
|
+
findings.push({ id: 'line-length', snippet: `~${Math.round(charsPerLine)} chars/line (aim for <${lineMax})` });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- Cramped padding --- (browser-only: needs rect to skip small badges/labels)
|
|
147
|
+
// Vertical and horizontal thresholds are independent because line-height
|
|
148
|
+
// already provides built-in vertical breathing room (the line box is taller
|
|
149
|
+
// than the cap height), but horizontal has no equivalent. Both scale with
|
|
150
|
+
// font-size — bigger text demands proportionally more padding.
|
|
151
|
+
// vertical: max(4px, fontSize × 0.3)
|
|
152
|
+
// horizontal: max(8px, fontSize × 0.5)
|
|
153
|
+
const isInlineCode = tag === 'code' && !el.closest?.('pre');
|
|
154
|
+
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
|
155
|
+
const borders = {
|
|
156
|
+
top: parseFloat(style.borderTopWidth) || 0,
|
|
157
|
+
right: parseFloat(style.borderRightWidth) || 0,
|
|
158
|
+
bottom: parseFloat(style.borderBottomWidth) || 0,
|
|
159
|
+
left: parseFloat(style.borderLeftWidth) || 0,
|
|
160
|
+
};
|
|
161
|
+
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
|
162
|
+
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
|
163
|
+
if (borderCount >= 2 || hasBg) {
|
|
164
|
+
const vPads = [], hPads = [];
|
|
165
|
+
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
|
166
|
+
if (hasBg || borders.bottom > 0) vPads.push(parseFloat(style.paddingBottom) || 0);
|
|
167
|
+
if (hasBg || borders.left > 0) hPads.push(parseFloat(style.paddingLeft) || 0);
|
|
168
|
+
if (hasBg || borders.right > 0) hPads.push(parseFloat(style.paddingRight) || 0);
|
|
169
|
+
|
|
170
|
+
const vMin = vPads.length ? Math.min(...vPads) : Infinity;
|
|
171
|
+
const hMin = hPads.length ? Math.min(...hPads) : Infinity;
|
|
172
|
+
const vThresh = Math.max(4, fontSize * 0.3);
|
|
173
|
+
const hThresh = Math.max(8, fontSize * 0.5);
|
|
174
|
+
|
|
175
|
+
// Emit at most one finding per element — pick whichever axis is worse.
|
|
176
|
+
if (vMin < vThresh) {
|
|
177
|
+
findings.push({ id: 'cramped-padding', snippet: `${vMin}px vertical padding (need ≥${vThresh.toFixed(1)}px for ${fontSize}px text)` });
|
|
178
|
+
} else if (hMin < hThresh) {
|
|
179
|
+
findings.push({ id: 'cramped-padding', snippet: `${hMin}px horizontal padding (need ≥${hThresh.toFixed(1)}px for ${fontSize}px text)` });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Flush against a visible boundary ---
|
|
185
|
+
// Fires when a container has a visible boundary (border, outline, OR a
|
|
186
|
+
// non-transparent background) AND near-zero padding on the bounded
|
|
187
|
+
// side(s) AND text-bearing children land flush against the boundary.
|
|
188
|
+
//
|
|
189
|
+
// Distinct from cramped-padding: that rule needs the element itself to
|
|
190
|
+
// have direct text (hasDirectText). This rule targets the OPPOSITE
|
|
191
|
+
// shape — a container with NO direct text, only children — which is
|
|
192
|
+
// exactly what cramped-padding misses (a section wrapping a label +
|
|
193
|
+
// list lands a free pass).
|
|
194
|
+
//
|
|
195
|
+
// The classic shape: agent writes `padding: 28px 0 0` shorthand on a
|
|
196
|
+
// section that also has a border, zeroing horizontal padding so the
|
|
197
|
+
// text-bearing children touch the side borders. Background and
|
|
198
|
+
// outline count too: a colored card with zero padding has the same
|
|
199
|
+
// visual failure mode.
|
|
200
|
+
{
|
|
201
|
+
const FLUSH_SKIP_TAGS = new Set(['HTML', 'BODY', 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ARTICLE', 'ASIDE', 'BUTTON', 'A', 'LABEL', 'SUMMARY', 'CODE', 'PRE', 'INPUT', 'TEXTAREA', 'SELECT', 'FORM', 'FIGURE', 'TABLE', 'TBODY', 'THEAD', 'TR', 'TD', 'TH']);
|
|
202
|
+
const upperTag = tag ? tag.toUpperCase() : '';
|
|
203
|
+
const elPosition = style.position || '';
|
|
204
|
+
if (
|
|
205
|
+
!FLUSH_SKIP_TAGS.has(upperTag) &&
|
|
206
|
+
!hasDirectText &&
|
|
207
|
+
!['fixed', 'absolute'].includes(elPosition) &&
|
|
208
|
+
el.children && el.children.length > 0
|
|
209
|
+
) {
|
|
210
|
+
const borderW = {
|
|
211
|
+
top: parseFloat(style.borderTopWidth) || 0,
|
|
212
|
+
right: parseFloat(style.borderRightWidth) || 0,
|
|
213
|
+
bottom: parseFloat(style.borderBottomWidth) || 0,
|
|
214
|
+
left: parseFloat(style.borderLeftWidth) || 0,
|
|
215
|
+
};
|
|
216
|
+
const borderVisible = {
|
|
217
|
+
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
|
218
|
+
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
|
219
|
+
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
|
220
|
+
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
|
221
|
+
};
|
|
222
|
+
// Outline detection. jsdom decomposes `border` shorthand into
|
|
223
|
+
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
|
224
|
+
// the longhands come back empty when the value was set via the
|
|
225
|
+
// shorthand. Fall back to parsing `style.outline` ourselves.
|
|
226
|
+
let outlineW = parseFloat(style.outlineWidth) || 0;
|
|
227
|
+
let outlineStyleVal = style.outlineStyle || '';
|
|
228
|
+
let outlineColorVal = style.outlineColor || '';
|
|
229
|
+
if (!outlineW && style.outline) {
|
|
230
|
+
const wMatch = style.outline.match(/(\d+(?:\.\d+)?)\s*px/);
|
|
231
|
+
if (wMatch) outlineW = parseFloat(wMatch[1]) || 0;
|
|
232
|
+
if (!outlineStyleVal) {
|
|
233
|
+
outlineStyleVal = /\b(solid|dashed|dotted|double|groove|ridge|inset|outset)\b/.test(style.outline) ? 'solid' : '';
|
|
234
|
+
}
|
|
235
|
+
if (!outlineColorVal) {
|
|
236
|
+
const cMatch = style.outline.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)\s*$/);
|
|
237
|
+
if (cMatch) outlineColorVal = cMatch[1];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
|
241
|
+
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
|
242
|
+
|
|
243
|
+
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
|
244
|
+
if (anyVisible) {
|
|
245
|
+
// Resolve padding to px (jsdom returns raw "1.5rem" etc., not the
|
|
246
|
+
// computed px value; parseFloat would strip the unit and treat
|
|
247
|
+
// 1.5rem as 1.5px, false-flagging legitimate insets).
|
|
248
|
+
const pad = {
|
|
249
|
+
top: resolveLengthPx(style.paddingTop, fontSize) ?? 0,
|
|
250
|
+
right: resolveLengthPx(style.paddingRight, fontSize) ?? 0,
|
|
251
|
+
bottom: resolveLengthPx(style.paddingBottom, fontSize) ?? 0,
|
|
252
|
+
left: resolveLengthPx(style.paddingLeft, fontSize) ?? 0,
|
|
253
|
+
};
|
|
254
|
+
const PAD_THRESHOLD = 2;
|
|
255
|
+
// Children-insulate-this-side: a side is insulated if ANY direct
|
|
256
|
+
// child has its own padding ≥ 4px on that side. Rationale: in
|
|
257
|
+
// typical flow, only the first/last (or leftmost/rightmost)
|
|
258
|
+
// children actually sit at the parent's edges. If even one of
|
|
259
|
+
// them has its own padding, the visual flush is broken on that
|
|
260
|
+
// side. Classic example: a column-flow card frame where the
|
|
261
|
+
// top child (header) has padding-top:12 and the bottom child
|
|
262
|
+
// (footer) has padding-bottom:8 — the parent's padding:0 doesn't
|
|
263
|
+
// matter; nothing is actually flush. The `any-child-insulates`
|
|
264
|
+
// heuristic accepts some false negatives (a card with one heavily
|
|
265
|
+
// padded middle child won't flag) for far fewer false positives.
|
|
266
|
+
const CHILD_INSULATE_THRESHOLD = 4;
|
|
267
|
+
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
|
268
|
+
for (const child of el.children) {
|
|
269
|
+
const childStyle = getComputedStyleFor(win, child);
|
|
270
|
+
if (!childStyle) continue;
|
|
271
|
+
const childPad = {
|
|
272
|
+
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
|
273
|
+
right: resolveLengthPx(childStyle.paddingRight, fontSize) ?? 0,
|
|
274
|
+
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
|
275
|
+
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
|
276
|
+
};
|
|
277
|
+
const childMargin = {
|
|
278
|
+
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
|
279
|
+
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
|
280
|
+
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
|
281
|
+
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
|
282
|
+
};
|
|
283
|
+
if (rect && typeof child.getBoundingClientRect === 'function') {
|
|
284
|
+
try {
|
|
285
|
+
const childRect = child.getBoundingClientRect();
|
|
286
|
+
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
|
287
|
+
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
|
288
|
+
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
|
289
|
+
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
|
290
|
+
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
|
291
|
+
}
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
for (const s of ['top', 'right', 'bottom', 'left']) {
|
|
295
|
+
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
|
296
|
+
childrenInsulate[s] = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
|
302
|
+
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
|
303
|
+
const flushSides = [];
|
|
304
|
+
for (const side of ['top', 'right', 'bottom', 'left']) {
|
|
305
|
+
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
|
306
|
+
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
|
307
|
+
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
|
308
|
+
flushSides.push(side);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (flushSides.length > 0) {
|
|
313
|
+
// Confirm at least one direct child has substantial text content
|
|
314
|
+
// (> 4 chars). Without this, the flush is harmless: e.g. an
|
|
315
|
+
// image-only card.
|
|
316
|
+
let hasTextChild = false;
|
|
317
|
+
for (const child of el.children) {
|
|
318
|
+
const childText = (child.textContent || '').trim();
|
|
319
|
+
if (childText.length > 4) { hasTextChild = true; break; }
|
|
320
|
+
}
|
|
321
|
+
if (hasTextChild) {
|
|
322
|
+
const cls = (typeof el.className === 'string' && el.className.trim())
|
|
323
|
+
? el.className.trim().split(/\s+/)[0]
|
|
324
|
+
: '';
|
|
325
|
+
const boundaryParts = [];
|
|
326
|
+
const borderSidesVisible = ['top', 'right', 'bottom', 'left'].filter(s => borderVisible[s]);
|
|
327
|
+
if (borderSidesVisible.length === 4) boundaryParts.push('border');
|
|
328
|
+
else if (borderSidesVisible.length > 0) boundaryParts.push(`border-${borderSidesVisible.join('/')}`);
|
|
329
|
+
if (outlineVisible) boundaryParts.push('outline');
|
|
330
|
+
if (bgVisible) boundaryParts.push('bg');
|
|
331
|
+
const sidesLabel = flushSides.length === 4 ? 'all sides' : flushSides.join('/');
|
|
332
|
+
const ident = cls
|
|
333
|
+
? `<${tag.toLowerCase()}> "${cls}"`
|
|
334
|
+
: `<${tag.toLowerCase()}>`;
|
|
335
|
+
findings.push({
|
|
336
|
+
id: 'cramped-padding',
|
|
337
|
+
snippet: `${ident}: children flush against ${boundaryParts.join('+')} on ${sidesLabel} (no inset)`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Body text touching viewport edge --- (browser-only: needs rect)
|
|
346
|
+
// Catches the failure mode where the agent ships body paragraphs
|
|
347
|
+
// with NO container providing horizontal padding — text bleeds
|
|
348
|
+
// directly to the viewport edge. Different from cramped-padding,
|
|
349
|
+
// which requires a colored/bordered container. Here the failure
|
|
350
|
+
// is the absence of the container entirely.
|
|
351
|
+
//
|
|
352
|
+
// Gate aggressively to avoid false positives:
|
|
353
|
+
// - <p> or <li> only (body content; not headings, not nav, not
|
|
354
|
+
// wrappers)
|
|
355
|
+
// - text > 40 chars (paragraph-like, not a label)
|
|
356
|
+
// - rect.width > 50% of viewport (real body, not a pull-quote)
|
|
357
|
+
// - rect.left < 16 OR rect.right > viewport - 16 (actually
|
|
358
|
+
// touching the edge)
|
|
359
|
+
// - not inside <nav> or <header> (those legitimately bleed)
|
|
360
|
+
// - element itself has no background-color (intentional full-bleed
|
|
361
|
+
// sections set a bg-color and provide their own internal padding)
|
|
362
|
+
if (rect && hasDirectText && textLen > 40 && ['P', 'LI'].includes(tag.toUpperCase()) && viewportWidth > 0) {
|
|
363
|
+
const inNavHeader = el.closest && (el.closest('nav') || el.closest('header'));
|
|
364
|
+
const hasOwnBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
|
|
365
|
+
const isPositioned = ['fixed', 'absolute'].includes(style.position || '');
|
|
366
|
+
const widthRatio = rect.width / viewportWidth;
|
|
367
|
+
const leftClose = rect.left < 16;
|
|
368
|
+
const rightClose = rect.right > viewportWidth - 16;
|
|
369
|
+
if (!inNavHeader && !hasOwnBg && !isPositioned && widthRatio > 0.5 && (leftClose || rightClose)) {
|
|
370
|
+
const which = leftClose && rightClose
|
|
371
|
+
? `left ${Math.round(rect.left)}px / right ${Math.round(viewportWidth - rect.right)}px`
|
|
372
|
+
: leftClose
|
|
373
|
+
? `left ${Math.round(rect.left)}px`
|
|
374
|
+
: `right ${Math.round(viewportWidth - rect.right)}px`;
|
|
375
|
+
findings.push({ id: 'body-text-viewport-edge', snippet: `<${tag.toLowerCase()}> with ${textLen}-char body bleeds to viewport edge (${which})` });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- Tight line height ---
|
|
380
|
+
if (hasDirectText && textLen > 50 && !['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
|
381
|
+
if (lineHeightPx != null && fontSize > 0) {
|
|
382
|
+
const ratio = lineHeightPx / fontSize;
|
|
383
|
+
if (ratio > 0 && ratio < 1.3) {
|
|
384
|
+
findings.push({ id: 'tight-leading', snippet: `line-height ${ratio.toFixed(2)}x (need >=1.3)` });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- Justified text (without hyphens) ---
|
|
390
|
+
if (hasDirectText && style.textAlign === 'justify') {
|
|
391
|
+
const hyphens = style.hyphens || style.webkitHyphens || '';
|
|
392
|
+
if (hyphens !== 'auto') {
|
|
393
|
+
findings.push({ id: 'justified-text', snippet: 'text-align: justify without hyphens: auto' });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- Tiny body text ---
|
|
398
|
+
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
|
399
|
+
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
|
400
|
+
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
|
401
|
+
const inUIContext = el.closest?.('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
|
402
|
+
const isUppercase = style.textTransform === 'uppercase';
|
|
403
|
+
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
|
404
|
+
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- All-caps body text ---
|
|
409
|
+
if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
|
|
410
|
+
if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
|
|
411
|
+
findings.push({ id: 'all-caps-body', snippet: `text-transform: uppercase on ${textLen} chars of body text` });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// --- Wide letter spacing on body text ---
|
|
416
|
+
if (hasDirectText && textLen > 20 && style.textTransform !== 'uppercase') {
|
|
417
|
+
if (letterSpacingPx != null && letterSpacingPx > 0 && fontSize > 0) {
|
|
418
|
+
const trackingEm = letterSpacingPx / fontSize;
|
|
419
|
+
if (trackingEm > 0.05) {
|
|
420
|
+
findings.push({ id: 'wide-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em on body text` });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// --- Crushed letter spacing (mirror of wide-tracking) ---
|
|
426
|
+
// Tracking pulled tighter than ~-0.05em crushes characters into each other.
|
|
427
|
+
// Optical tightening that display type legitimately wants (around -0.02em)
|
|
428
|
+
// stays well above this floor.
|
|
429
|
+
if (hasDirectText && textLen > 20 && fontSize > 0) {
|
|
430
|
+
if (letterSpacingPx != null && letterSpacingPx < 0) {
|
|
431
|
+
const trackingEm = letterSpacingPx / fontSize;
|
|
432
|
+
if (trackingEm <= -0.05) {
|
|
433
|
+
const excerpt = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
434
|
+
findings.push({ id: 'extreme-negative-tracking', snippet: `letter-spacing: ${trackingEm.toFixed(2)}em — "${excerpt}"` });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return findings;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export {
|
|
443
|
+
resolveFontSizePx,
|
|
444
|
+
resolveLengthPx,
|
|
445
|
+
cssColorIsTransparent,
|
|
446
|
+
checkQuality,
|
|
447
|
+
};
|