@bastani/atomic 0.9.0-alpha.3 → 0.9.0
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/CHANGELOG.md +48 -0
- package/dist/builtin/cursor/CHANGELOG.md +7 -0
- package/dist/builtin/cursor/package.json +2 -2
- package/dist/builtin/intercom/CHANGELOG.md +8 -0
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/CHANGELOG.md +12 -0
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/CHANGELOG.md +17 -0
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/CHANGELOG.md +8 -0
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +50 -0
- package/dist/builtin/workflows/README.md +12 -12
- package/dist/builtin/workflows/builtin/goal-prompts.ts +8 -0
- package/dist/builtin/workflows/builtin/goal-runner.ts +96 -1
- package/dist/builtin/workflows/builtin/goal-types.ts +2 -0
- package/dist/builtin/workflows/builtin/goal.d.ts +3 -0
- package/dist/builtin/workflows/builtin/goal.ts +12 -1
- package/dist/builtin/workflows/builtin/index.d.ts +8 -8
- package/dist/builtin/workflows/builtin/open-claude-design-feedback.ts +359 -0
- package/dist/builtin/workflows/builtin/open-claude-design-phases.ts +254 -352
- package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +256 -414
- package/dist/builtin/workflows/builtin/open-claude-design-setup.ts +272 -0
- package/dist/builtin/workflows/builtin/open-claude-design-utils.ts +58 -68
- package/dist/builtin/workflows/builtin/open-claude-design.d.ts +5 -9
- package/dist/builtin/workflows/builtin/open-claude-design.ts +14 -26
- package/dist/builtin/workflows/package.json +1 -1
- package/dist/builtin/workflows/skills/impeccable/SKILL.md +14 -23
- package/dist/builtin/workflows/skills/impeccable/reference/brand.md +2 -2
- package/dist/builtin/workflows/skills/impeccable/reference/live.md +25 -4
- package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +724 -29
- package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +219 -7
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +57 -11
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +750 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +648 -53
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +7 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +29 -4
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +44 -11
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +29 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +27 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +29 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +401 -46
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/inline-ignores.mjs +148 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +6 -6
- package/dist/builtin/workflows/skills/impeccable/scripts/{design-parser.mjs → lib/design-parser.mjs} +8 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +638 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-paths.mjs +128 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/{is-generated.mjs → lib/is-generated.mjs} +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/lib/target-args.mjs +42 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/browser-script-parts.mjs +49 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/{live-completion.mjs → live/completion.mjs} +1 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/{live-event-validation.mjs → live/event-validation.mjs} +6 -5
- package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-apply.mjs +939 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/{live-manual-edits-buffer.mjs → live/manual-edits-buffer.mjs} +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/{live-session-store.mjs → live/session-store.mjs} +21 -3
- package/dist/builtin/workflows/skills/impeccable/scripts/live/svelte-component.mjs +835 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +274 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/ui-core.mjs +180 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live/vocabulary.mjs +36 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +185 -60
- package/dist/builtin/workflows/skills/impeccable/scripts/live-browser-dom.js +146 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +3369 -1026
- package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/live-complete.mjs +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +133 -9
- package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +42 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +4 -4
- package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +21 -15
- package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +1 -1
- package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +205 -1269
- package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/live-target.mjs +30 -0
- package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +69 -26
- package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +73 -22
- package/dist/core/atomic-guide-command.d.ts.map +1 -1
- package/dist/core/atomic-guide-command.js +5 -5
- package/dist/core/atomic-guide-command.js.map +1 -1
- package/docs/index.md +2 -2
- package/docs/quickstart.md +9 -9
- package/docs/workflows.md +42 -23
- package/package.json +2 -2
- package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +0 -284
- package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +0 -126
- /package/dist/builtin/workflows/skills/impeccable/scripts/{live-insert-ui.mjs → live/insert-ui.mjs} +0 -0
|
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
|
|
|
514
514
|
// --- Motion ---
|
|
515
515
|
|
|
516
516
|
// Bounce/elastic animation names
|
|
517
|
-
const bounceRe = /animation(?:-name)?\s*:\s*[^;]
|
|
518
|
-
|
|
519
|
-
|
|
517
|
+
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
|
|
518
|
+
const bounceMatch = bounceRe.exec(html);
|
|
519
|
+
if (bounceMatch) {
|
|
520
|
+
const animationToken = bounceMatch[1]
|
|
521
|
+
.split(/[,\s]+/)
|
|
522
|
+
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
523
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
|
520
524
|
}
|
|
521
525
|
|
|
522
526
|
// Overshoot cubic-bezier
|
|
@@ -974,11 +978,16 @@ function parseAnyColor(s) {
|
|
|
974
978
|
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
975
979
|
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
976
980
|
// Match L (with optional %), then C and H separated permissively.
|
|
977
|
-
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
|
981
|
+
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
978
982
|
if (m) {
|
|
979
983
|
const Lnum = parseFloat(m[1]);
|
|
980
984
|
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
981
|
-
|
|
985
|
+
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
986
|
+
if (m[5] !== undefined) {
|
|
987
|
+
const alpha = parseFloat(m[5]);
|
|
988
|
+
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
989
|
+
}
|
|
990
|
+
return rgb;
|
|
982
991
|
}
|
|
983
992
|
return null;
|
|
984
993
|
}
|
|
@@ -1007,9 +1016,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
|
|
1007
1016
|
'[role="navigation"]',
|
|
1008
1017
|
'[aria-label*="breadcrumb" i]',
|
|
1009
1018
|
'[class*="breadcrumb" i]',
|
|
1019
|
+
'[aria-hidden="true"]',
|
|
1010
1020
|
'[data-impeccable-allow-kickers]',
|
|
1011
1021
|
].join(',');
|
|
1012
1022
|
|
|
1023
|
+
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
|
1024
|
+
'article',
|
|
1025
|
+
'button',
|
|
1026
|
+
'a',
|
|
1027
|
+
'li',
|
|
1028
|
+
'[role="listitem"]',
|
|
1029
|
+
'[role="option"]',
|
|
1030
|
+
].join(',');
|
|
1031
|
+
|
|
1013
1032
|
function cleanInlineText(el) {
|
|
1014
1033
|
return [...el.childNodes]
|
|
1015
1034
|
.filter(n => n.nodeType === 3)
|
|
@@ -1019,6 +1038,11 @@ function cleanInlineText(el) {
|
|
|
1019
1038
|
.trim();
|
|
1020
1039
|
}
|
|
1021
1040
|
|
|
1041
|
+
function isRepeatedKickerCardContext(heading, kicker) {
|
|
1042
|
+
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
|
1043
|
+
return Boolean(item && (!item.contains || item.contains(kicker)));
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1022
1046
|
function isRepeatedKickerCandidate(opts) {
|
|
1023
1047
|
const {
|
|
1024
1048
|
headingTag,
|
|
@@ -1032,6 +1056,7 @@ function isRepeatedKickerCandidate(opts) {
|
|
|
1032
1056
|
} = opts;
|
|
1033
1057
|
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
|
1034
1058
|
if (!headingText || headingText.length < 3) return false;
|
|
1059
|
+
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
|
1035
1060
|
if (!(headingFontSize >= 20)) return false;
|
|
1036
1061
|
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
|
1037
1062
|
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
|
@@ -1053,6 +1078,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
|
|
1053
1078
|
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
1054
1079
|
const kicker = heading.previousElementSibling;
|
|
1055
1080
|
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
1081
|
+
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
|
1056
1082
|
|
|
1057
1083
|
const headingStyle = getStyle(heading);
|
|
1058
1084
|
const kickerStyle = getStyle(kicker);
|
|
@@ -1235,6 +1261,84 @@ function resolveLengthPx(value, fontSizePx) {
|
|
|
1235
1261
|
return num * fontSizePx;
|
|
1236
1262
|
}
|
|
1237
1263
|
|
|
1264
|
+
function cssColorIsTransparent(value) {
|
|
1265
|
+
if (!value) return true;
|
|
1266
|
+
const str = String(value).trim().toLowerCase();
|
|
1267
|
+
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
|
1268
|
+
const parsed = parseAnyColor(str);
|
|
1269
|
+
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
|
1270
|
+
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function colorsNearlyMatch(a, b) {
|
|
1274
|
+
const ca = parseAnyColor(a);
|
|
1275
|
+
const cb = parseAnyColor(b);
|
|
1276
|
+
if (!ca || !cb) return false;
|
|
1277
|
+
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
|
1278
|
+
const channelDelta = Math.max(
|
|
1279
|
+
Math.abs(ca.r - cb.r),
|
|
1280
|
+
Math.abs(ca.g - cb.g),
|
|
1281
|
+
Math.abs(ca.b - cb.b),
|
|
1282
|
+
);
|
|
1283
|
+
return alphaDelta <= 0.03 && channelDelta <= 3;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function getComputedStyleFor(win, el) {
|
|
1287
|
+
if (win && typeof win.getComputedStyle === 'function') {
|
|
1288
|
+
try { return win.getComputedStyle(el); } catch {}
|
|
1289
|
+
}
|
|
1290
|
+
if (typeof getComputedStyle === 'function') {
|
|
1291
|
+
try { return getComputedStyle(el); } catch {}
|
|
1292
|
+
}
|
|
1293
|
+
return null;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function hasVisibleBackgroundBoundary(style, el, win) {
|
|
1297
|
+
const bg = style?.backgroundColor || '';
|
|
1298
|
+
if (cssColorIsTransparent(bg)) return false;
|
|
1299
|
+
|
|
1300
|
+
let parent = el?.parentElement || null;
|
|
1301
|
+
while (parent) {
|
|
1302
|
+
const parentStyle = getComputedStyleFor(win, parent);
|
|
1303
|
+
const parentBg = parentStyle?.backgroundColor || '';
|
|
1304
|
+
if (!cssColorIsTransparent(parentBg)) {
|
|
1305
|
+
return !colorsNearlyMatch(bg, parentBg);
|
|
1306
|
+
}
|
|
1307
|
+
parent = parent.parentElement;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
return true;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
|
1314
|
+
|
|
1315
|
+
function hasMeaningfulDirectText(node) {
|
|
1316
|
+
if (!node?.childNodes) return false;
|
|
1317
|
+
for (const child of node.childNodes) {
|
|
1318
|
+
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
|
1319
|
+
}
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function textDescendantsFlushSides(el, rect) {
|
|
1324
|
+
const flush = { top: false, right: false, bottom: false, left: false };
|
|
1325
|
+
if (!rect || !el?.querySelectorAll) return flush;
|
|
1326
|
+
const TEXT_EDGE_THRESHOLD = 4;
|
|
1327
|
+
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
|
1328
|
+
for (const node of candidates) {
|
|
1329
|
+
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
|
1330
|
+
let nodeRect = null;
|
|
1331
|
+
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
|
1332
|
+
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
|
1333
|
+
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
|
1334
|
+
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
|
1335
|
+
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
|
1336
|
+
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
|
1337
|
+
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
|
1338
|
+
}
|
|
1339
|
+
return flush;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1238
1342
|
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
|
1239
1343
|
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
|
1240
1344
|
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
|
@@ -1264,7 +1368,8 @@ function checkQuality(opts) {
|
|
|
1264
1368
|
// font-size — bigger text demands proportionally more padding.
|
|
1265
1369
|
// vertical: max(4px, fontSize × 0.3)
|
|
1266
1370
|
// horizontal: max(8px, fontSize × 0.5)
|
|
1267
|
-
|
|
1371
|
+
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
|
1372
|
+
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
|
1268
1373
|
const borders = {
|
|
1269
1374
|
top: parseFloat(style.borderTopWidth) || 0,
|
|
1270
1375
|
right: parseFloat(style.borderRightWidth) || 0,
|
|
@@ -1272,7 +1377,7 @@ function checkQuality(opts) {
|
|
|
1272
1377
|
left: parseFloat(style.borderLeftWidth) || 0,
|
|
1273
1378
|
};
|
|
1274
1379
|
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
|
1275
|
-
const hasBg = style
|
|
1380
|
+
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
|
1276
1381
|
if (borderCount >= 2 || hasBg) {
|
|
1277
1382
|
const vPads = [], hPads = [];
|
|
1278
1383
|
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
|
@@ -1320,10 +1425,6 @@ function checkQuality(opts) {
|
|
|
1320
1425
|
!['fixed', 'absolute'].includes(elPosition) &&
|
|
1321
1426
|
el.children && el.children.length > 0
|
|
1322
1427
|
) {
|
|
1323
|
-
const isTransparent = (c) =>
|
|
1324
|
-
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
|
1325
|
-
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
|
1326
|
-
|
|
1327
1428
|
const borderW = {
|
|
1328
1429
|
top: parseFloat(style.borderTopWidth) || 0,
|
|
1329
1430
|
right: parseFloat(style.borderRightWidth) || 0,
|
|
@@ -1331,10 +1432,10 @@ function checkQuality(opts) {
|
|
|
1331
1432
|
left: parseFloat(style.borderLeftWidth) || 0,
|
|
1332
1433
|
};
|
|
1333
1434
|
const borderVisible = {
|
|
1334
|
-
top: borderW.top > 0 && !
|
|
1335
|
-
right: borderW.right > 0 && !
|
|
1336
|
-
bottom: borderW.bottom > 0 && !
|
|
1337
|
-
left: borderW.left > 0 && !
|
|
1435
|
+
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
|
1436
|
+
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
|
1437
|
+
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
|
1438
|
+
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
|
1338
1439
|
};
|
|
1339
1440
|
// Outline detection. jsdom decomposes `border` shorthand into
|
|
1340
1441
|
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
|
@@ -1354,8 +1455,8 @@ function checkQuality(opts) {
|
|
|
1354
1455
|
if (cMatch) outlineColorVal = cMatch[1];
|
|
1355
1456
|
}
|
|
1356
1457
|
}
|
|
1357
|
-
const outlineVisible = outlineW > 0 && !
|
|
1358
|
-
const bgVisible =
|
|
1458
|
+
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
|
1459
|
+
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
|
1359
1460
|
|
|
1360
1461
|
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
|
1361
1462
|
if (anyVisible) {
|
|
@@ -1383,13 +1484,7 @@ function checkQuality(opts) {
|
|
|
1383
1484
|
const CHILD_INSULATE_THRESHOLD = 4;
|
|
1384
1485
|
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
|
1385
1486
|
for (const child of el.children) {
|
|
1386
|
-
let childStyle =
|
|
1387
|
-
if (win && typeof win.getComputedStyle === 'function') {
|
|
1388
|
-
try { childStyle = win.getComputedStyle(child); } catch {}
|
|
1389
|
-
}
|
|
1390
|
-
if (!childStyle && typeof getComputedStyle === 'function') {
|
|
1391
|
-
try { childStyle = getComputedStyle(child); } catch {}
|
|
1392
|
-
}
|
|
1487
|
+
let childStyle = getComputedStyleFor(win, child);
|
|
1393
1488
|
if (!childStyle) continue;
|
|
1394
1489
|
const childPad = {
|
|
1395
1490
|
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
|
@@ -1397,15 +1492,37 @@ function checkQuality(opts) {
|
|
|
1397
1492
|
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
|
1398
1493
|
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
|
1399
1494
|
};
|
|
1495
|
+
const childMargin = {
|
|
1496
|
+
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
|
1497
|
+
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
|
1498
|
+
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
|
1499
|
+
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
|
1500
|
+
};
|
|
1501
|
+
if (rect && typeof child.getBoundingClientRect === 'function') {
|
|
1502
|
+
try {
|
|
1503
|
+
const childRect = child.getBoundingClientRect();
|
|
1504
|
+
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
|
1505
|
+
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
|
1506
|
+
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
|
1507
|
+
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
|
1508
|
+
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
|
1509
|
+
}
|
|
1510
|
+
} catch {}
|
|
1511
|
+
}
|
|
1400
1512
|
for (const s of ['top', 'right', 'bottom', 'left']) {
|
|
1401
|
-
if (childPad[s] >= CHILD_INSULATE_THRESHOLD
|
|
1513
|
+
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
|
1514
|
+
childrenInsulate[s] = true;
|
|
1515
|
+
}
|
|
1402
1516
|
}
|
|
1403
1517
|
}
|
|
1404
1518
|
|
|
1519
|
+
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
|
1520
|
+
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
|
1405
1521
|
const flushSides = [];
|
|
1406
1522
|
for (const side of ['top', 'right', 'bottom', 'left']) {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1523
|
+
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
|
1524
|
+
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
|
1525
|
+
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
|
1409
1526
|
flushSides.push(side);
|
|
1410
1527
|
}
|
|
1411
1528
|
}
|
|
@@ -1499,7 +1616,7 @@ function checkQuality(opts) {
|
|
|
1499
1616
|
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
|
1500
1617
|
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
|
1501
1618
|
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
|
1502
|
-
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="
|
|
1619
|
+
const inUIContext = el.closest && 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]');
|
|
1503
1620
|
const isUppercase = style.textTransform === 'uppercase';
|
|
1504
1621
|
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
|
1505
1622
|
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
|
@@ -2107,17 +2224,28 @@ function checkCreamPalette(doc, win) {
|
|
|
2107
2224
|
}
|
|
2108
2225
|
|
|
2109
2226
|
// ─── Oversized hero headline ────────────────────────────────────────────────
|
|
2110
|
-
// Fires when a *long* headline is set at display size
|
|
2111
|
-
//
|
|
2112
|
-
//
|
|
2113
|
-
//
|
|
2227
|
+
// Fires when a *long* headline is set at display size and actually dominates
|
|
2228
|
+
// the viewport. A punchy one- or two-word headline at the same size is a
|
|
2229
|
+
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
|
2230
|
+
// pass too — length and viewport share together are the tell.
|
|
2114
2231
|
const OVERSIZED_H1_FONT_PX = 72;
|
|
2115
2232
|
const OVERSIZED_H1_MIN_CHARS = 40;
|
|
2116
|
-
|
|
2233
|
+
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
|
2234
|
+
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
|
2235
|
+
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
|
2117
2236
|
if (tag !== 'h1') return [];
|
|
2118
2237
|
const textLen = headingText.length;
|
|
2119
2238
|
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
|
2120
|
-
|
|
2239
|
+
let viewportDetail = '';
|
|
2240
|
+
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
|
2241
|
+
const heightRatio = rect.height / viewportHeight;
|
|
2242
|
+
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
|
2243
|
+
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
|
2244
|
+
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
|
2245
|
+
if (!dominatesViewport) return [];
|
|
2246
|
+
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
|
2247
|
+
}
|
|
2248
|
+
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
|
2121
2249
|
}
|
|
2122
2250
|
return [];
|
|
2123
2251
|
}
|
|
@@ -2135,31 +2263,54 @@ function checkElementOversizedH1DOM(el) {
|
|
|
2135
2263
|
const style = getComputedStyle(el);
|
|
2136
2264
|
const fontSize = parseFloat(style.fontSize) || 0;
|
|
2137
2265
|
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
2138
|
-
|
|
2266
|
+
const rect = el.getBoundingClientRect();
|
|
2267
|
+
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
|
2268
|
+
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
|
2269
|
+
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
|
2139
2270
|
}
|
|
2140
2271
|
|
|
2141
2272
|
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
|
2142
|
-
|
|
2273
|
+
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
|
2274
|
+
|
|
2275
|
+
function shadowLayerAlpha(layer) {
|
|
2276
|
+
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
|
2277
|
+
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
|
2278
|
+
if (!match) return 1;
|
|
2279
|
+
if (match[0].toLowerCase() === 'transparent') return 0;
|
|
2280
|
+
const parsed = parseAnyColor(match[0]);
|
|
2281
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
|
2143
2285
|
if (!boxShadow || boxShadow === 'none') return 0;
|
|
2144
2286
|
let maxBlur = 0;
|
|
2145
2287
|
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
|
2146
2288
|
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
|
2289
|
+
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
|
2147
2290
|
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
|
2148
2291
|
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
|
2149
2292
|
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
|
2150
2293
|
// both reduce to the same numbers here.
|
|
2151
|
-
const cleaned = layer.replace(
|
|
2294
|
+
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
|
2152
2295
|
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
|
2153
2296
|
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
|
2154
2297
|
}
|
|
2155
2298
|
return maxBlur;
|
|
2156
2299
|
}
|
|
2157
2300
|
|
|
2158
|
-
function
|
|
2159
|
-
|
|
2160
|
-
const
|
|
2161
|
-
|
|
2162
|
-
|
|
2301
|
+
function cssColorAlpha(value) {
|
|
2302
|
+
if (cssColorIsTransparent(value)) return 0;
|
|
2303
|
+
const parsed = parseAnyColor(value);
|
|
2304
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
|
2308
|
+
const visibleThinBorders = borderWidths
|
|
2309
|
+
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
|
2310
|
+
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
|
2311
|
+
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
|
2312
|
+
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
|
2313
|
+
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
|
2163
2314
|
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
|
2164
2315
|
}
|
|
2165
2316
|
return [];
|
|
@@ -2174,13 +2325,22 @@ function borderWidthsFromStyle(style) {
|
|
|
2174
2325
|
];
|
|
2175
2326
|
}
|
|
2176
2327
|
|
|
2328
|
+
function borderColorsFromStyle(style) {
|
|
2329
|
+
return [
|
|
2330
|
+
style.borderTopColor || '',
|
|
2331
|
+
style.borderRightColor || '',
|
|
2332
|
+
style.borderBottomColor || '',
|
|
2333
|
+
style.borderLeftColor || '',
|
|
2334
|
+
];
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2177
2337
|
function checkElementGptBorderShadow(el, style) {
|
|
2178
|
-
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2338
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2179
2339
|
}
|
|
2180
2340
|
|
|
2181
2341
|
function checkElementGptBorderShadowDOM(el) {
|
|
2182
2342
|
const style = getComputedStyle(el);
|
|
2183
|
-
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2343
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2184
2344
|
}
|
|
2185
2345
|
|
|
2186
2346
|
// ─── Clipped overflow container ───────────────────────────────────────────────
|
|
@@ -2193,17 +2353,131 @@ function classSelector(el) {
|
|
|
2193
2353
|
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
|
2194
2354
|
}
|
|
2195
2355
|
|
|
2356
|
+
function positionedChildIsDecorative(child) {
|
|
2357
|
+
if (!child || typeof child.getAttribute !== 'function') return false;
|
|
2358
|
+
if (child.closest?.('[aria-hidden="true"]')) return true;
|
|
2359
|
+
const role = (child.getAttribute('role') || '').toLowerCase();
|
|
2360
|
+
if (role === 'none' || role === 'presentation') return true;
|
|
2361
|
+
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
|
2362
|
+
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
|
2363
|
+
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
|
2364
|
+
if (
|
|
2365
|
+
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
|
2366
|
+
!positionedChildHasSubstantiveContent(child)
|
|
2367
|
+
) {
|
|
2368
|
+
return true;
|
|
2369
|
+
}
|
|
2370
|
+
return false;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
|
2374
|
+
'a[href]',
|
|
2375
|
+
'button',
|
|
2376
|
+
'input',
|
|
2377
|
+
'select',
|
|
2378
|
+
'summary',
|
|
2379
|
+
'textarea',
|
|
2380
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
2381
|
+
'[role="button"]',
|
|
2382
|
+
'[role="dialog"]',
|
|
2383
|
+
'[role="link"]',
|
|
2384
|
+
'[role="listbox"]',
|
|
2385
|
+
'[role="menu"]',
|
|
2386
|
+
'[role="menuitem"]',
|
|
2387
|
+
'[role="option"]',
|
|
2388
|
+
'[role="tooltip"]',
|
|
2389
|
+
].join(',');
|
|
2390
|
+
|
|
2391
|
+
function positionedChildHasSubstantiveContent(child) {
|
|
2392
|
+
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2393
|
+
if (text.length > 0) return true;
|
|
2394
|
+
if (typeof child.matches === 'function') {
|
|
2395
|
+
try {
|
|
2396
|
+
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
2397
|
+
} catch {}
|
|
2398
|
+
}
|
|
2399
|
+
if (typeof child.querySelector === 'function') {
|
|
2400
|
+
try {
|
|
2401
|
+
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
2402
|
+
} catch {}
|
|
2403
|
+
}
|
|
2404
|
+
return false;
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function clippingContainerIsIntentionalViewport(el) {
|
|
2408
|
+
if (!el || typeof el.getAttribute !== 'function') return false;
|
|
2409
|
+
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
|
2410
|
+
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
|
2411
|
+
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
|
2412
|
+
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
|
2413
|
+
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function elementRect(el) {
|
|
2417
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
|
2418
|
+
try {
|
|
2419
|
+
const rect = el.getBoundingClientRect();
|
|
2420
|
+
if (!rect) return null;
|
|
2421
|
+
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
|
2422
|
+
if (!values.every(Number.isFinite)) return null;
|
|
2423
|
+
if (rect.width <= 0 && rect.height <= 0) return null;
|
|
2424
|
+
return rect;
|
|
2425
|
+
} catch {
|
|
2426
|
+
return null;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
function positionedStyleImpliesEscape(style) {
|
|
2431
|
+
const values = [
|
|
2432
|
+
style.top,
|
|
2433
|
+
style.right,
|
|
2434
|
+
style.bottom,
|
|
2435
|
+
style.left,
|
|
2436
|
+
style.inset,
|
|
2437
|
+
style.insetBlock,
|
|
2438
|
+
style.insetInline,
|
|
2439
|
+
style.insetBlockStart,
|
|
2440
|
+
style.insetBlockEnd,
|
|
2441
|
+
style.insetInlineStart,
|
|
2442
|
+
style.insetInlineEnd,
|
|
2443
|
+
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
|
2444
|
+
for (const value of values) {
|
|
2445
|
+
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
|
2446
|
+
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
|
2447
|
+
}
|
|
2448
|
+
return false;
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
|
2452
|
+
const parentRect = elementRect(el);
|
|
2453
|
+
const childRect = elementRect(child);
|
|
2454
|
+
if (!parentRect || !childRect) return null;
|
|
2455
|
+
const threshold = 2;
|
|
2456
|
+
return Boolean(
|
|
2457
|
+
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
|
2458
|
+
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2196
2462
|
function checkClippedOverflow(el, style, getStyle) {
|
|
2197
2463
|
const clips = (v) => v === 'hidden' || v === 'clip';
|
|
2198
2464
|
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
|
2199
2465
|
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
|
2200
|
-
const
|
|
2466
|
+
const clipX = clips(ox) || clips(ov);
|
|
2467
|
+
const clipY = clips(oy) || clips(ov);
|
|
2468
|
+
const anyClip = clipX || clipY;
|
|
2201
2469
|
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
|
2202
2470
|
if (!anyClip || anyScroll) return [];
|
|
2471
|
+
if (clippingContainerIsIntentionalViewport(el)) return [];
|
|
2203
2472
|
if (!el.querySelectorAll) return [];
|
|
2204
2473
|
for (const child of el.querySelectorAll('*')) {
|
|
2205
|
-
const
|
|
2474
|
+
const childStyle = getStyle(child);
|
|
2475
|
+
const pos = childStyle.position || '';
|
|
2206
2476
|
if (pos === 'absolute' || pos === 'fixed') {
|
|
2477
|
+
if (positionedChildIsDecorative(child)) continue;
|
|
2478
|
+
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
|
2479
|
+
if (escapes === false) continue;
|
|
2480
|
+
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
|
2207
2481
|
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
|
2208
2482
|
}
|
|
2209
2483
|
}
|
|
@@ -2222,14 +2496,94 @@ function checkElementClippedOverflowDOM(el) {
|
|
|
2222
2496
|
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
|
2223
2497
|
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
|
2224
2498
|
|
|
2499
|
+
function metricLengthPx(value, fontSizePx = 16) {
|
|
2500
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
2501
|
+
if (typeof value !== 'string') return null;
|
|
2502
|
+
return resolveLengthPx(value, fontSizePx);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
function firstMetricLengthPx(fontSizePx, ...values) {
|
|
2506
|
+
for (const value of values) {
|
|
2507
|
+
const parsed = metricLengthPx(value, fontSizePx);
|
|
2508
|
+
if (parsed !== null) return parsed;
|
|
2509
|
+
}
|
|
2510
|
+
return null;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function expandBoxShorthand(parts) {
|
|
2514
|
+
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
|
2515
|
+
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
|
2516
|
+
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
|
2517
|
+
return [parts[0], parts[1], parts[2], parts[3]];
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function clippedByInset(clipPath) {
|
|
2521
|
+
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
|
2522
|
+
if (!match) return false;
|
|
2523
|
+
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
|
2524
|
+
if (!beforeRound) return false;
|
|
2525
|
+
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
|
2526
|
+
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
|
2527
|
+
if (percents.some(match => !match)) return false;
|
|
2528
|
+
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
|
2529
|
+
return top + bottom >= 100 || left + right >= 100;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function clippedByRect(clip) {
|
|
2533
|
+
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
|
2534
|
+
if (!match) return false;
|
|
2535
|
+
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
|
2536
|
+
if (values.length !== 4) return false;
|
|
2537
|
+
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
|
2538
|
+
if ([top, right, bottom, left].some(value => value === null)) return false;
|
|
2539
|
+
return bottom <= top || right <= left;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
|
2543
|
+
if (!style) return false;
|
|
2544
|
+
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
|
2545
|
+
.map(value => String(value || '').toLowerCase());
|
|
2546
|
+
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
|
2547
|
+
|
|
2548
|
+
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
|
2549
|
+
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
|
2550
|
+
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
|
2551
|
+
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
|
2552
|
+
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
|
2553
|
+
|
|
2554
|
+
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
|
2555
|
+
const clip = String(style.clip || '').trim();
|
|
2556
|
+
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
function isRenderedForBrowserRule(el) {
|
|
2560
|
+
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
|
2561
|
+
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
|
2562
|
+
const style = getComputedStyle(cur);
|
|
2563
|
+
const visibility = String(style.visibility || '').toLowerCase();
|
|
2564
|
+
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
|
2565
|
+
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
|
2566
|
+
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
|
2567
|
+
}
|
|
2568
|
+
return true;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2225
2571
|
function checkElementTextOverflowDOM(el) {
|
|
2226
2572
|
const tag = el.tagName.toLowerCase();
|
|
2227
2573
|
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
|
2574
|
+
if (!isRenderedForBrowserRule(el)) return [];
|
|
2228
2575
|
// Only the element that actually owns overflowing text — not its ancestors,
|
|
2229
2576
|
// which inherit a wider scrollWidth from the spilling descendant.
|
|
2230
2577
|
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
|
2231
2578
|
if (!hasDirectText) return [];
|
|
2232
2579
|
const style = getComputedStyle(el);
|
|
2580
|
+
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
2581
|
+
if (isScreenReaderOnlyTextStyle(style, {
|
|
2582
|
+
width: rect?.width,
|
|
2583
|
+
height: rect?.height,
|
|
2584
|
+
clientWidth: el.clientWidth,
|
|
2585
|
+
clientHeight: el.clientHeight,
|
|
2586
|
+
})) return [];
|
|
2233
2587
|
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
|
2234
2588
|
if (isScrollRegion(style)) return [];
|
|
2235
2589
|
// A scrollable ancestor means this overflow is intentional and scrollable.
|
|
@@ -2312,5 +2666,6 @@ export {
|
|
|
2312
2666
|
checkClippedOverflow,
|
|
2313
2667
|
checkElementClippedOverflow,
|
|
2314
2668
|
checkElementClippedOverflowDOM,
|
|
2669
|
+
isScreenReaderOnlyTextStyle,
|
|
2315
2670
|
checkElementTextOverflowDOM,
|
|
2316
2671
|
};
|