@bastani/atomic 0.9.0-alpha.2 → 0.9.0-alpha.4
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 +21 -0
- package/dist/builtin/cursor/package.json +2 -2
- package/dist/builtin/intercom/package.json +1 -1
- package/dist/builtin/mcp/package.json +1 -1
- package/dist/builtin/subagents/package.json +1 -1
- package/dist/builtin/web-access/package.json +1 -1
- package/dist/builtin/workflows/CHANGELOG.md +24 -0
- package/dist/builtin/workflows/README.md +12 -12
- package/dist/builtin/workflows/builtin/goal-ledger.ts +2 -0
- package/dist/builtin/workflows/builtin/goal-prompts.ts +8 -0
- package/dist/builtin/workflows/builtin/goal-reports.ts +5 -0
- package/dist/builtin/workflows/builtin/goal-runner.ts +103 -4
- package/dist/builtin/workflows/builtin/goal-types.ts +4 -0
- package/dist/builtin/workflows/builtin/goal.d.ts +4 -0
- package/dist/builtin/workflows/builtin/goal.ts +14 -2
- 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/builtin/prompt-refinement.ts +102 -0
- package/dist/builtin/workflows/builtin/ralph-core.ts +6 -4
- package/dist/builtin/workflows/builtin/ralph-runner.ts +22 -24
- package/dist/builtin/workflows/builtin/ralph.d.ts +2 -0
- package/dist/builtin/workflows/builtin/ralph.ts +3 -1
- 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/builtin/workflows/src/extension/workflow-prompts.ts +3 -1
- 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/dist/core/system-prompt.d.ts.map +1 -1
- package/dist/core/system-prompt.js +0 -1
- package/dist/core/system-prompt.js.map +1 -1
- package/docs/index.md +2 -2
- package/docs/quickstart.md +9 -9
- package/docs/workflows.md +816 -47
- 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
package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js
CHANGED
|
@@ -425,6 +425,35 @@ const ANTIPATTERNS = [
|
|
|
425
425
|
skillSection: 'Layout & Space',
|
|
426
426
|
skillGuideline: 'overflow container clipping positioned children',
|
|
427
427
|
},
|
|
428
|
+
{
|
|
429
|
+
id: 'design-system-font',
|
|
430
|
+
category: 'quality',
|
|
431
|
+
name: 'Font outside DESIGN.md',
|
|
432
|
+
description:
|
|
433
|
+
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
|
434
|
+
skillSection: 'Typography',
|
|
435
|
+
skillGuideline: 'font family outside the project design system',
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: 'design-system-color',
|
|
439
|
+
category: 'quality',
|
|
440
|
+
severity: 'advisory',
|
|
441
|
+
name: 'Color outside DESIGN.md',
|
|
442
|
+
description:
|
|
443
|
+
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
|
|
444
|
+
skillSection: 'Color & Contrast',
|
|
445
|
+
skillGuideline: 'literal color outside the project design system',
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: 'design-system-radius',
|
|
449
|
+
category: 'quality',
|
|
450
|
+
severity: 'advisory',
|
|
451
|
+
name: 'Radius outside DESIGN.md',
|
|
452
|
+
description:
|
|
453
|
+
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
|
|
454
|
+
skillSection: 'Visual Details',
|
|
455
|
+
skillGuideline: 'border radius outside the project design system',
|
|
456
|
+
},
|
|
428
457
|
|
|
429
458
|
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
|
430
459
|
{
|
|
@@ -1084,9 +1113,13 @@ function checkHtmlPatterns(html) {
|
|
|
1084
1113
|
// --- Motion ---
|
|
1085
1114
|
|
|
1086
1115
|
// Bounce/elastic animation names
|
|
1087
|
-
const bounceRe = /animation(?:-name)?\s*:\s*[^;]
|
|
1088
|
-
|
|
1089
|
-
|
|
1116
|
+
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
|
|
1117
|
+
const bounceMatch = bounceRe.exec(html);
|
|
1118
|
+
if (bounceMatch) {
|
|
1119
|
+
const animationToken = bounceMatch[1]
|
|
1120
|
+
.split(/[,\s]+/)
|
|
1121
|
+
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
1122
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
|
|
1090
1123
|
}
|
|
1091
1124
|
|
|
1092
1125
|
// Overshoot cubic-bezier
|
|
@@ -1544,11 +1577,16 @@ function parseAnyColor(s) {
|
|
|
1544
1577
|
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
1545
1578
|
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
1546
1579
|
// Match L (with optional %), then C and H separated permissively.
|
|
1547
|
-
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
|
1580
|
+
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
1548
1581
|
if (m) {
|
|
1549
1582
|
const Lnum = parseFloat(m[1]);
|
|
1550
1583
|
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
1551
|
-
|
|
1584
|
+
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
1585
|
+
if (m[5] !== undefined) {
|
|
1586
|
+
const alpha = parseFloat(m[5]);
|
|
1587
|
+
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
1588
|
+
}
|
|
1589
|
+
return rgb;
|
|
1552
1590
|
}
|
|
1553
1591
|
return null;
|
|
1554
1592
|
}
|
|
@@ -1577,9 +1615,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
|
|
1577
1615
|
'[role="navigation"]',
|
|
1578
1616
|
'[aria-label*="breadcrumb" i]',
|
|
1579
1617
|
'[class*="breadcrumb" i]',
|
|
1618
|
+
'[aria-hidden="true"]',
|
|
1580
1619
|
'[data-impeccable-allow-kickers]',
|
|
1581
1620
|
].join(',');
|
|
1582
1621
|
|
|
1622
|
+
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
|
1623
|
+
'article',
|
|
1624
|
+
'button',
|
|
1625
|
+
'a',
|
|
1626
|
+
'li',
|
|
1627
|
+
'[role="listitem"]',
|
|
1628
|
+
'[role="option"]',
|
|
1629
|
+
].join(',');
|
|
1630
|
+
|
|
1583
1631
|
function cleanInlineText(el) {
|
|
1584
1632
|
return [...el.childNodes]
|
|
1585
1633
|
.filter(n => n.nodeType === 3)
|
|
@@ -1589,6 +1637,11 @@ function cleanInlineText(el) {
|
|
|
1589
1637
|
.trim();
|
|
1590
1638
|
}
|
|
1591
1639
|
|
|
1640
|
+
function isRepeatedKickerCardContext(heading, kicker) {
|
|
1641
|
+
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
|
1642
|
+
return Boolean(item && (!item.contains || item.contains(kicker)));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1592
1645
|
function isRepeatedKickerCandidate(opts) {
|
|
1593
1646
|
const {
|
|
1594
1647
|
headingTag,
|
|
@@ -1602,6 +1655,7 @@ function isRepeatedKickerCandidate(opts) {
|
|
|
1602
1655
|
} = opts;
|
|
1603
1656
|
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
|
1604
1657
|
if (!headingText || headingText.length < 3) return false;
|
|
1658
|
+
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
|
1605
1659
|
if (!(headingFontSize >= 20)) return false;
|
|
1606
1660
|
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
|
1607
1661
|
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
|
@@ -1623,6 +1677,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
|
|
1623
1677
|
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
1624
1678
|
const kicker = heading.previousElementSibling;
|
|
1625
1679
|
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
|
1680
|
+
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
|
1626
1681
|
|
|
1627
1682
|
const headingStyle = getStyle(heading);
|
|
1628
1683
|
const kickerStyle = getStyle(kicker);
|
|
@@ -1805,6 +1860,84 @@ function resolveLengthPx(value, fontSizePx) {
|
|
|
1805
1860
|
return num * fontSizePx;
|
|
1806
1861
|
}
|
|
1807
1862
|
|
|
1863
|
+
function cssColorIsTransparent(value) {
|
|
1864
|
+
if (!value) return true;
|
|
1865
|
+
const str = String(value).trim().toLowerCase();
|
|
1866
|
+
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
|
1867
|
+
const parsed = parseAnyColor(str);
|
|
1868
|
+
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
|
1869
|
+
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function colorsNearlyMatch(a, b) {
|
|
1873
|
+
const ca = parseAnyColor(a);
|
|
1874
|
+
const cb = parseAnyColor(b);
|
|
1875
|
+
if (!ca || !cb) return false;
|
|
1876
|
+
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
|
1877
|
+
const channelDelta = Math.max(
|
|
1878
|
+
Math.abs(ca.r - cb.r),
|
|
1879
|
+
Math.abs(ca.g - cb.g),
|
|
1880
|
+
Math.abs(ca.b - cb.b),
|
|
1881
|
+
);
|
|
1882
|
+
return alphaDelta <= 0.03 && channelDelta <= 3;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function getComputedStyleFor(win, el) {
|
|
1886
|
+
if (win && typeof win.getComputedStyle === 'function') {
|
|
1887
|
+
try { return win.getComputedStyle(el); } catch {}
|
|
1888
|
+
}
|
|
1889
|
+
if (typeof getComputedStyle === 'function') {
|
|
1890
|
+
try { return getComputedStyle(el); } catch {}
|
|
1891
|
+
}
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function hasVisibleBackgroundBoundary(style, el, win) {
|
|
1896
|
+
const bg = style?.backgroundColor || '';
|
|
1897
|
+
if (cssColorIsTransparent(bg)) return false;
|
|
1898
|
+
|
|
1899
|
+
let parent = el?.parentElement || null;
|
|
1900
|
+
while (parent) {
|
|
1901
|
+
const parentStyle = getComputedStyleFor(win, parent);
|
|
1902
|
+
const parentBg = parentStyle?.backgroundColor || '';
|
|
1903
|
+
if (!cssColorIsTransparent(parentBg)) {
|
|
1904
|
+
return !colorsNearlyMatch(bg, parentBg);
|
|
1905
|
+
}
|
|
1906
|
+
parent = parent.parentElement;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
return true;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
|
1913
|
+
|
|
1914
|
+
function hasMeaningfulDirectText(node) {
|
|
1915
|
+
if (!node?.childNodes) return false;
|
|
1916
|
+
for (const child of node.childNodes) {
|
|
1917
|
+
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
|
1918
|
+
}
|
|
1919
|
+
return false;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function textDescendantsFlushSides(el, rect) {
|
|
1923
|
+
const flush = { top: false, right: false, bottom: false, left: false };
|
|
1924
|
+
if (!rect || !el?.querySelectorAll) return flush;
|
|
1925
|
+
const TEXT_EDGE_THRESHOLD = 4;
|
|
1926
|
+
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
|
1927
|
+
for (const node of candidates) {
|
|
1928
|
+
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
|
1929
|
+
let nodeRect = null;
|
|
1930
|
+
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
|
1931
|
+
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
|
1932
|
+
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
|
1933
|
+
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
|
1934
|
+
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
|
1935
|
+
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
|
1936
|
+
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
|
1937
|
+
}
|
|
1938
|
+
return flush;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1808
1941
|
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
|
1809
1942
|
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
|
1810
1943
|
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
|
@@ -1834,7 +1967,8 @@ function checkQuality(opts) {
|
|
|
1834
1967
|
// font-size — bigger text demands proportionally more padding.
|
|
1835
1968
|
// vertical: max(4px, fontSize × 0.3)
|
|
1836
1969
|
// horizontal: max(8px, fontSize × 0.5)
|
|
1837
|
-
|
|
1970
|
+
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
|
1971
|
+
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
|
1838
1972
|
const borders = {
|
|
1839
1973
|
top: parseFloat(style.borderTopWidth) || 0,
|
|
1840
1974
|
right: parseFloat(style.borderRightWidth) || 0,
|
|
@@ -1842,7 +1976,7 @@ function checkQuality(opts) {
|
|
|
1842
1976
|
left: parseFloat(style.borderLeftWidth) || 0,
|
|
1843
1977
|
};
|
|
1844
1978
|
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
|
1845
|
-
const hasBg = style
|
|
1979
|
+
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
|
1846
1980
|
if (borderCount >= 2 || hasBg) {
|
|
1847
1981
|
const vPads = [], hPads = [];
|
|
1848
1982
|
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
|
@@ -1890,10 +2024,6 @@ function checkQuality(opts) {
|
|
|
1890
2024
|
!['fixed', 'absolute'].includes(elPosition) &&
|
|
1891
2025
|
el.children && el.children.length > 0
|
|
1892
2026
|
) {
|
|
1893
|
-
const isTransparent = (c) =>
|
|
1894
|
-
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
|
1895
|
-
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
|
1896
|
-
|
|
1897
2027
|
const borderW = {
|
|
1898
2028
|
top: parseFloat(style.borderTopWidth) || 0,
|
|
1899
2029
|
right: parseFloat(style.borderRightWidth) || 0,
|
|
@@ -1901,10 +2031,10 @@ function checkQuality(opts) {
|
|
|
1901
2031
|
left: parseFloat(style.borderLeftWidth) || 0,
|
|
1902
2032
|
};
|
|
1903
2033
|
const borderVisible = {
|
|
1904
|
-
top: borderW.top > 0 && !
|
|
1905
|
-
right: borderW.right > 0 && !
|
|
1906
|
-
bottom: borderW.bottom > 0 && !
|
|
1907
|
-
left: borderW.left > 0 && !
|
|
2034
|
+
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
|
2035
|
+
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
|
2036
|
+
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
|
2037
|
+
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
|
1908
2038
|
};
|
|
1909
2039
|
// Outline detection. jsdom decomposes `border` shorthand into
|
|
1910
2040
|
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
|
@@ -1924,8 +2054,8 @@ function checkQuality(opts) {
|
|
|
1924
2054
|
if (cMatch) outlineColorVal = cMatch[1];
|
|
1925
2055
|
}
|
|
1926
2056
|
}
|
|
1927
|
-
const outlineVisible = outlineW > 0 && !
|
|
1928
|
-
const bgVisible =
|
|
2057
|
+
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
|
2058
|
+
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
|
1929
2059
|
|
|
1930
2060
|
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
|
1931
2061
|
if (anyVisible) {
|
|
@@ -1953,13 +2083,7 @@ function checkQuality(opts) {
|
|
|
1953
2083
|
const CHILD_INSULATE_THRESHOLD = 4;
|
|
1954
2084
|
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
|
1955
2085
|
for (const child of el.children) {
|
|
1956
|
-
let childStyle =
|
|
1957
|
-
if (win && typeof win.getComputedStyle === 'function') {
|
|
1958
|
-
try { childStyle = win.getComputedStyle(child); } catch {}
|
|
1959
|
-
}
|
|
1960
|
-
if (!childStyle && typeof getComputedStyle === 'function') {
|
|
1961
|
-
try { childStyle = getComputedStyle(child); } catch {}
|
|
1962
|
-
}
|
|
2086
|
+
let childStyle = getComputedStyleFor(win, child);
|
|
1963
2087
|
if (!childStyle) continue;
|
|
1964
2088
|
const childPad = {
|
|
1965
2089
|
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
|
@@ -1967,15 +2091,37 @@ function checkQuality(opts) {
|
|
|
1967
2091
|
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
|
1968
2092
|
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
|
1969
2093
|
};
|
|
2094
|
+
const childMargin = {
|
|
2095
|
+
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
|
2096
|
+
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
|
2097
|
+
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
|
2098
|
+
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
|
2099
|
+
};
|
|
2100
|
+
if (rect && typeof child.getBoundingClientRect === 'function') {
|
|
2101
|
+
try {
|
|
2102
|
+
const childRect = child.getBoundingClientRect();
|
|
2103
|
+
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
|
2104
|
+
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
|
2105
|
+
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
|
2106
|
+
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
|
2107
|
+
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
|
2108
|
+
}
|
|
2109
|
+
} catch {}
|
|
2110
|
+
}
|
|
1970
2111
|
for (const s of ['top', 'right', 'bottom', 'left']) {
|
|
1971
|
-
if (childPad[s] >= CHILD_INSULATE_THRESHOLD
|
|
2112
|
+
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
|
2113
|
+
childrenInsulate[s] = true;
|
|
2114
|
+
}
|
|
1972
2115
|
}
|
|
1973
2116
|
}
|
|
1974
2117
|
|
|
2118
|
+
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
|
2119
|
+
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
|
1975
2120
|
const flushSides = [];
|
|
1976
2121
|
for (const side of ['top', 'right', 'bottom', 'left']) {
|
|
1977
|
-
const
|
|
1978
|
-
|
|
2122
|
+
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
|
2123
|
+
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
|
2124
|
+
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
|
1979
2125
|
flushSides.push(side);
|
|
1980
2126
|
}
|
|
1981
2127
|
}
|
|
@@ -2069,7 +2215,7 @@ function checkQuality(opts) {
|
|
|
2069
2215
|
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
|
2070
2216
|
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
|
2071
2217
|
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
|
2072
|
-
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*="
|
|
2218
|
+
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]');
|
|
2073
2219
|
const isUppercase = style.textTransform === 'uppercase';
|
|
2074
2220
|
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
|
2075
2221
|
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
|
@@ -2677,17 +2823,28 @@ function checkCreamPalette(doc, win) {
|
|
|
2677
2823
|
}
|
|
2678
2824
|
|
|
2679
2825
|
// ─── Oversized hero headline ────────────────────────────────────────────────
|
|
2680
|
-
// Fires when a *long* headline is set at display size
|
|
2681
|
-
//
|
|
2682
|
-
//
|
|
2683
|
-
//
|
|
2826
|
+
// Fires when a *long* headline is set at display size and actually dominates
|
|
2827
|
+
// the viewport. A punchy one- or two-word headline at the same size is a
|
|
2828
|
+
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
|
2829
|
+
// pass too — length and viewport share together are the tell.
|
|
2684
2830
|
const OVERSIZED_H1_FONT_PX = 72;
|
|
2685
2831
|
const OVERSIZED_H1_MIN_CHARS = 40;
|
|
2686
|
-
|
|
2832
|
+
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
|
2833
|
+
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
|
2834
|
+
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
|
2687
2835
|
if (tag !== 'h1') return [];
|
|
2688
2836
|
const textLen = headingText.length;
|
|
2689
2837
|
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
|
2690
|
-
|
|
2838
|
+
let viewportDetail = '';
|
|
2839
|
+
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
|
2840
|
+
const heightRatio = rect.height / viewportHeight;
|
|
2841
|
+
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
|
2842
|
+
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
|
2843
|
+
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
|
2844
|
+
if (!dominatesViewport) return [];
|
|
2845
|
+
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
|
2846
|
+
}
|
|
2847
|
+
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
|
2691
2848
|
}
|
|
2692
2849
|
return [];
|
|
2693
2850
|
}
|
|
@@ -2705,31 +2862,54 @@ function checkElementOversizedH1DOM(el) {
|
|
|
2705
2862
|
const style = getComputedStyle(el);
|
|
2706
2863
|
const fontSize = parseFloat(style.fontSize) || 0;
|
|
2707
2864
|
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
2708
|
-
|
|
2865
|
+
const rect = el.getBoundingClientRect();
|
|
2866
|
+
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
|
2867
|
+
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
|
2868
|
+
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
|
2709
2869
|
}
|
|
2710
2870
|
|
|
2711
2871
|
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
|
2712
|
-
|
|
2872
|
+
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;
|
|
2873
|
+
|
|
2874
|
+
function shadowLayerAlpha(layer) {
|
|
2875
|
+
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
|
2876
|
+
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
|
2877
|
+
if (!match) return 1;
|
|
2878
|
+
if (match[0].toLowerCase() === 'transparent') return 0;
|
|
2879
|
+
const parsed = parseAnyColor(match[0]);
|
|
2880
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
|
2713
2884
|
if (!boxShadow || boxShadow === 'none') return 0;
|
|
2714
2885
|
let maxBlur = 0;
|
|
2715
2886
|
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
|
2716
2887
|
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
|
2888
|
+
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
|
2717
2889
|
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
|
2718
2890
|
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
|
2719
2891
|
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
|
2720
2892
|
// both reduce to the same numbers here.
|
|
2721
|
-
const cleaned = layer.replace(
|
|
2893
|
+
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
|
2722
2894
|
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
|
2723
2895
|
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
|
2724
2896
|
}
|
|
2725
2897
|
return maxBlur;
|
|
2726
2898
|
}
|
|
2727
2899
|
|
|
2728
|
-
function
|
|
2729
|
-
|
|
2730
|
-
const
|
|
2731
|
-
|
|
2732
|
-
|
|
2900
|
+
function cssColorAlpha(value) {
|
|
2901
|
+
if (cssColorIsTransparent(value)) return 0;
|
|
2902
|
+
const parsed = parseAnyColor(value);
|
|
2903
|
+
return parsed ? (parsed.a ?? 1) : 1;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
|
2907
|
+
const visibleThinBorders = borderWidths
|
|
2908
|
+
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
|
2909
|
+
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
|
2910
|
+
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
|
2911
|
+
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
|
2912
|
+
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
|
2733
2913
|
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
|
2734
2914
|
}
|
|
2735
2915
|
return [];
|
|
@@ -2744,13 +2924,22 @@ function borderWidthsFromStyle(style) {
|
|
|
2744
2924
|
];
|
|
2745
2925
|
}
|
|
2746
2926
|
|
|
2927
|
+
function borderColorsFromStyle(style) {
|
|
2928
|
+
return [
|
|
2929
|
+
style.borderTopColor || '',
|
|
2930
|
+
style.borderRightColor || '',
|
|
2931
|
+
style.borderBottomColor || '',
|
|
2932
|
+
style.borderLeftColor || '',
|
|
2933
|
+
];
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2747
2936
|
function checkElementGptBorderShadow(el, style) {
|
|
2748
|
-
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2937
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2749
2938
|
}
|
|
2750
2939
|
|
|
2751
2940
|
function checkElementGptBorderShadowDOM(el) {
|
|
2752
2941
|
const style = getComputedStyle(el);
|
|
2753
|
-
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2942
|
+
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
|
2754
2943
|
}
|
|
2755
2944
|
|
|
2756
2945
|
// ─── Clipped overflow container ───────────────────────────────────────────────
|
|
@@ -2763,17 +2952,131 @@ function classSelector(el) {
|
|
|
2763
2952
|
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
|
2764
2953
|
}
|
|
2765
2954
|
|
|
2955
|
+
function positionedChildIsDecorative(child) {
|
|
2956
|
+
if (!child || typeof child.getAttribute !== 'function') return false;
|
|
2957
|
+
if (child.closest?.('[aria-hidden="true"]')) return true;
|
|
2958
|
+
const role = (child.getAttribute('role') || '').toLowerCase();
|
|
2959
|
+
if (role === 'none' || role === 'presentation') return true;
|
|
2960
|
+
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
|
2961
|
+
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
|
2962
|
+
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
|
2963
|
+
if (
|
|
2964
|
+
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
|
2965
|
+
!positionedChildHasSubstantiveContent(child)
|
|
2966
|
+
) {
|
|
2967
|
+
return true;
|
|
2968
|
+
}
|
|
2969
|
+
return false;
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
|
2973
|
+
'a[href]',
|
|
2974
|
+
'button',
|
|
2975
|
+
'input',
|
|
2976
|
+
'select',
|
|
2977
|
+
'summary',
|
|
2978
|
+
'textarea',
|
|
2979
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
2980
|
+
'[role="button"]',
|
|
2981
|
+
'[role="dialog"]',
|
|
2982
|
+
'[role="link"]',
|
|
2983
|
+
'[role="listbox"]',
|
|
2984
|
+
'[role="menu"]',
|
|
2985
|
+
'[role="menuitem"]',
|
|
2986
|
+
'[role="option"]',
|
|
2987
|
+
'[role="tooltip"]',
|
|
2988
|
+
].join(',');
|
|
2989
|
+
|
|
2990
|
+
function positionedChildHasSubstantiveContent(child) {
|
|
2991
|
+
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2992
|
+
if (text.length > 0) return true;
|
|
2993
|
+
if (typeof child.matches === 'function') {
|
|
2994
|
+
try {
|
|
2995
|
+
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
2996
|
+
} catch {}
|
|
2997
|
+
}
|
|
2998
|
+
if (typeof child.querySelector === 'function') {
|
|
2999
|
+
try {
|
|
3000
|
+
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
|
3001
|
+
} catch {}
|
|
3002
|
+
}
|
|
3003
|
+
return false;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
function clippingContainerIsIntentionalViewport(el) {
|
|
3007
|
+
if (!el || typeof el.getAttribute !== 'function') return false;
|
|
3008
|
+
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
|
3009
|
+
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
|
3010
|
+
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
|
3011
|
+
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
|
3012
|
+
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
function elementRect(el) {
|
|
3016
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
|
3017
|
+
try {
|
|
3018
|
+
const rect = el.getBoundingClientRect();
|
|
3019
|
+
if (!rect) return null;
|
|
3020
|
+
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
|
3021
|
+
if (!values.every(Number.isFinite)) return null;
|
|
3022
|
+
if (rect.width <= 0 && rect.height <= 0) return null;
|
|
3023
|
+
return rect;
|
|
3024
|
+
} catch {
|
|
3025
|
+
return null;
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
function positionedStyleImpliesEscape(style) {
|
|
3030
|
+
const values = [
|
|
3031
|
+
style.top,
|
|
3032
|
+
style.right,
|
|
3033
|
+
style.bottom,
|
|
3034
|
+
style.left,
|
|
3035
|
+
style.inset,
|
|
3036
|
+
style.insetBlock,
|
|
3037
|
+
style.insetInline,
|
|
3038
|
+
style.insetBlockStart,
|
|
3039
|
+
style.insetBlockEnd,
|
|
3040
|
+
style.insetInlineStart,
|
|
3041
|
+
style.insetInlineEnd,
|
|
3042
|
+
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
|
3043
|
+
for (const value of values) {
|
|
3044
|
+
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
|
3045
|
+
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
|
3046
|
+
}
|
|
3047
|
+
return false;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
|
3051
|
+
const parentRect = elementRect(el);
|
|
3052
|
+
const childRect = elementRect(child);
|
|
3053
|
+
if (!parentRect || !childRect) return null;
|
|
3054
|
+
const threshold = 2;
|
|
3055
|
+
return Boolean(
|
|
3056
|
+
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
|
3057
|
+
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
|
3058
|
+
);
|
|
3059
|
+
}
|
|
3060
|
+
|
|
2766
3061
|
function checkClippedOverflow(el, style, getStyle) {
|
|
2767
3062
|
const clips = (v) => v === 'hidden' || v === 'clip';
|
|
2768
3063
|
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
|
2769
3064
|
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
|
2770
|
-
const
|
|
3065
|
+
const clipX = clips(ox) || clips(ov);
|
|
3066
|
+
const clipY = clips(oy) || clips(ov);
|
|
3067
|
+
const anyClip = clipX || clipY;
|
|
2771
3068
|
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
|
2772
3069
|
if (!anyClip || anyScroll) return [];
|
|
3070
|
+
if (clippingContainerIsIntentionalViewport(el)) return [];
|
|
2773
3071
|
if (!el.querySelectorAll) return [];
|
|
2774
3072
|
for (const child of el.querySelectorAll('*')) {
|
|
2775
|
-
const
|
|
3073
|
+
const childStyle = getStyle(child);
|
|
3074
|
+
const pos = childStyle.position || '';
|
|
2776
3075
|
if (pos === 'absolute' || pos === 'fixed') {
|
|
3076
|
+
if (positionedChildIsDecorative(child)) continue;
|
|
3077
|
+
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
|
3078
|
+
if (escapes === false) continue;
|
|
3079
|
+
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
|
2777
3080
|
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
|
2778
3081
|
}
|
|
2779
3082
|
}
|
|
@@ -2792,14 +3095,94 @@ function checkElementClippedOverflowDOM(el) {
|
|
|
2792
3095
|
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
|
2793
3096
|
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
|
2794
3097
|
|
|
3098
|
+
function metricLengthPx(value, fontSizePx = 16) {
|
|
3099
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
3100
|
+
if (typeof value !== 'string') return null;
|
|
3101
|
+
return resolveLengthPx(value, fontSizePx);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
function firstMetricLengthPx(fontSizePx, ...values) {
|
|
3105
|
+
for (const value of values) {
|
|
3106
|
+
const parsed = metricLengthPx(value, fontSizePx);
|
|
3107
|
+
if (parsed !== null) return parsed;
|
|
3108
|
+
}
|
|
3109
|
+
return null;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function expandBoxShorthand(parts) {
|
|
3113
|
+
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
|
3114
|
+
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
|
3115
|
+
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
|
3116
|
+
return [parts[0], parts[1], parts[2], parts[3]];
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
function clippedByInset(clipPath) {
|
|
3120
|
+
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
|
3121
|
+
if (!match) return false;
|
|
3122
|
+
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
|
3123
|
+
if (!beforeRound) return false;
|
|
3124
|
+
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
|
3125
|
+
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
|
3126
|
+
if (percents.some(match => !match)) return false;
|
|
3127
|
+
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
|
3128
|
+
return top + bottom >= 100 || left + right >= 100;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
function clippedByRect(clip) {
|
|
3132
|
+
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
|
3133
|
+
if (!match) return false;
|
|
3134
|
+
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
|
3135
|
+
if (values.length !== 4) return false;
|
|
3136
|
+
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
|
3137
|
+
if ([top, right, bottom, left].some(value => value === null)) return false;
|
|
3138
|
+
return bottom <= top || right <= left;
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
|
3142
|
+
if (!style) return false;
|
|
3143
|
+
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
|
3144
|
+
.map(value => String(value || '').toLowerCase());
|
|
3145
|
+
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
|
3146
|
+
|
|
3147
|
+
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
|
3148
|
+
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
|
3149
|
+
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
|
3150
|
+
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
|
3151
|
+
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
|
3152
|
+
|
|
3153
|
+
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
|
3154
|
+
const clip = String(style.clip || '').trim();
|
|
3155
|
+
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function isRenderedForBrowserRule(el) {
|
|
3159
|
+
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
|
3160
|
+
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
|
3161
|
+
const style = getComputedStyle(cur);
|
|
3162
|
+
const visibility = String(style.visibility || '').toLowerCase();
|
|
3163
|
+
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
|
3164
|
+
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
|
3165
|
+
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
|
3166
|
+
}
|
|
3167
|
+
return true;
|
|
3168
|
+
}
|
|
3169
|
+
|
|
2795
3170
|
function checkElementTextOverflowDOM(el) {
|
|
2796
3171
|
const tag = el.tagName.toLowerCase();
|
|
2797
3172
|
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
|
3173
|
+
if (!isRenderedForBrowserRule(el)) return [];
|
|
2798
3174
|
// Only the element that actually owns overflowing text — not its ancestors,
|
|
2799
3175
|
// which inherit a wider scrollWidth from the spilling descendant.
|
|
2800
3176
|
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
|
2801
3177
|
if (!hasDirectText) return [];
|
|
2802
3178
|
const style = getComputedStyle(el);
|
|
3179
|
+
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
3180
|
+
if (isScreenReaderOnlyTextStyle(style, {
|
|
3181
|
+
width: rect?.width,
|
|
3182
|
+
height: rect?.height,
|
|
3183
|
+
clientWidth: el.clientWidth,
|
|
3184
|
+
clientHeight: el.clientHeight,
|
|
3185
|
+
})) return [];
|
|
2803
3186
|
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
|
2804
3187
|
if (isScrollRegion(style)) return [];
|
|
2805
3188
|
// A scrollable ancestor means this overflow is intentional and scrollable.
|
|
@@ -3476,6 +3859,7 @@ if (IS_BROWSER) {
|
|
|
3476
3859
|
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
|
3477
3860
|
if (el.closest('[id^="impeccable-live-"]')) continue;
|
|
3478
3861
|
if (el === document.body || el === document.documentElement) continue;
|
|
3862
|
+
if (!isRenderedForBrowserRule(el)) continue;
|
|
3479
3863
|
|
|
3480
3864
|
const tag = el.tagName.toLowerCase();
|
|
3481
3865
|
const style = getComputedStyle(el);
|
|
@@ -3907,6 +4291,7 @@ if (IS_BROWSER) {
|
|
|
3907
4291
|
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
|
3908
4292
|
}
|
|
3909
4293
|
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
|
4294
|
+
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
|
3910
4295
|
|
|
3911
4296
|
const blockingReason = (candidate.reasons || []).find(reason =>
|
|
3912
4297
|
reason === 'background-clip text' ||
|
|
@@ -4038,6 +4423,7 @@ if (IS_BROWSER) {
|
|
|
4038
4423
|
category: ap ? ap.category : 'quality',
|
|
4039
4424
|
severity: ap?.severity || 'warning',
|
|
4040
4425
|
detail: f.detail || f.snippet,
|
|
4426
|
+
ignoreValue: f.ignoreValue || f.value || '',
|
|
4041
4427
|
name: ap ? ap.name : (f.type || f.id),
|
|
4042
4428
|
description: ap ? ap.description : '',
|
|
4043
4429
|
};
|
|
@@ -4074,10 +4460,203 @@ if (IS_BROWSER) {
|
|
|
4074
4460
|
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
|
|
4075
4461
|
}
|
|
4076
4462
|
|
|
4463
|
+
const DESIGN_COLOR_TOLERANCE = 6;
|
|
4464
|
+
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
|
|
4465
|
+
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
|
4466
|
+
|
|
4467
|
+
function normalizeBrowserFontName(value) {
|
|
4468
|
+
return String(value || '')
|
|
4469
|
+
.trim()
|
|
4470
|
+
.replace(/^["']|["']$/g, '')
|
|
4471
|
+
.replace(/\+/g, ' ')
|
|
4472
|
+
.replace(/\s+/g, ' ')
|
|
4473
|
+
.toLowerCase();
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
function browserPrimaryFont(stack) {
|
|
4477
|
+
if (!stack || /var\(/i.test(stack)) return '';
|
|
4478
|
+
return String(stack)
|
|
4479
|
+
.split(',')
|
|
4480
|
+
.map(normalizeBrowserFontName)
|
|
4481
|
+
.find(font => font && !GENERIC_FONTS.has(font)) || '';
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
function browserDesignSystemConfig() {
|
|
4485
|
+
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
|
|
4486
|
+
if (!raw?.present) return null;
|
|
4487
|
+
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
|
|
4488
|
+
const allowedColors = (raw.allowedColors || [])
|
|
4489
|
+
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
|
4490
|
+
.map(color => ({ r: color.r, g: color.g, b: color.b }));
|
|
4491
|
+
const allowedRadii = (raw.allowedRadii || [])
|
|
4492
|
+
.map(Number)
|
|
4493
|
+
.filter(px => Number.isFinite(px));
|
|
4494
|
+
return {
|
|
4495
|
+
present: true,
|
|
4496
|
+
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
|
|
4497
|
+
allowedFonts,
|
|
4498
|
+
hasColors: raw.hasColors === true && allowedColors.length > 0,
|
|
4499
|
+
allowedColors,
|
|
4500
|
+
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
|
|
4501
|
+
allowedRadii,
|
|
4502
|
+
hasPillRadius: raw.hasPillRadius === true,
|
|
4503
|
+
};
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
function browserColorsClose(a, b) {
|
|
4507
|
+
if (!a || !b) return false;
|
|
4508
|
+
return Math.max(
|
|
4509
|
+
Math.abs(a.r - b.r),
|
|
4510
|
+
Math.abs(a.g - b.g),
|
|
4511
|
+
Math.abs(a.b - b.b),
|
|
4512
|
+
) <= DESIGN_COLOR_TOLERANCE;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
function isBrowserDesignColorAllowed(raw, designSystem) {
|
|
4516
|
+
if (!designSystem?.hasColors) return true;
|
|
4517
|
+
const text = String(raw || '').trim().toLowerCase();
|
|
4518
|
+
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
|
|
4519
|
+
if (text.includes('var(')) return true;
|
|
4520
|
+
const parsed = parseAnyColor(text);
|
|
4521
|
+
if (!parsed) return true;
|
|
4522
|
+
if ((parsed.a ?? 1) <= 0.05) return true;
|
|
4523
|
+
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
function isBrowserTransparentCss(value) {
|
|
4527
|
+
const text = String(value || '').trim().toLowerCase();
|
|
4528
|
+
if (!text || text === 'transparent') return true;
|
|
4529
|
+
const parsed = parseAnyColor(text);
|
|
4530
|
+
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
function isBrowserDesignRadiusAllowed(raw, designSystem) {
|
|
4534
|
+
if (!designSystem?.hasRadii) return true;
|
|
4535
|
+
const text = String(raw || '').trim().toLowerCase();
|
|
4536
|
+
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
|
|
4537
|
+
if (text.includes('var(') || text.includes('%')) return true;
|
|
4538
|
+
const px = resolveLengthPx(text, 16);
|
|
4539
|
+
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
|
|
4540
|
+
if (designSystem.hasPillRadius && px >= 99) return true;
|
|
4541
|
+
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
function browserRadiusTokens(value) {
|
|
4545
|
+
return String(value || '')
|
|
4546
|
+
.replace(/\s*\/\s*/g, ' ')
|
|
4547
|
+
.split(/\s+/)
|
|
4548
|
+
.map(token => token.trim())
|
|
4549
|
+
.filter(Boolean);
|
|
4550
|
+
}
|
|
4551
|
+
|
|
4552
|
+
function browserHasDirectText(el) {
|
|
4553
|
+
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
function browserSampleText(el) {
|
|
4557
|
+
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
4558
|
+
return text ? ` "${text.slice(0, 40)}"` : '';
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
function shouldSkipDesignElement(el) {
|
|
4562
|
+
const tag = el.tagName?.toLowerCase?.() || '';
|
|
4563
|
+
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
|
|
4564
|
+
}
|
|
4565
|
+
|
|
4566
|
+
function checkElementDesignSystemDOM(el, designSystem, seen) {
|
|
4567
|
+
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
|
|
4568
|
+
const findings = [];
|
|
4569
|
+
const tag = el.tagName?.toLowerCase?.() || 'unknown';
|
|
4570
|
+
const style = getComputedStyle(el);
|
|
4571
|
+
|
|
4572
|
+
if (designSystem.hasFonts && browserHasDirectText(el)) {
|
|
4573
|
+
const font = browserPrimaryFont(style.fontFamily || '');
|
|
4574
|
+
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
|
|
4575
|
+
seen.fonts.add(font);
|
|
4576
|
+
findings.push({
|
|
4577
|
+
type: 'design-system-font',
|
|
4578
|
+
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
|
|
4579
|
+
ignoreValue: font,
|
|
4580
|
+
});
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
|
|
4584
|
+
if (designSystem.hasColors) {
|
|
4585
|
+
const colorChecks = [];
|
|
4586
|
+
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
|
|
4587
|
+
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
|
|
4588
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
4589
|
+
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
|
|
4590
|
+
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
|
|
4591
|
+
}
|
|
4592
|
+
}
|
|
4593
|
+
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
|
|
4594
|
+
|
|
4595
|
+
for (const [kind, raw] of colorChecks) {
|
|
4596
|
+
const label = String(raw || '').trim().replace(/\s+/g, ' ');
|
|
4597
|
+
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
|
|
4598
|
+
const key = `${kind}:${label}`;
|
|
4599
|
+
if (seen.colors.has(key)) continue;
|
|
4600
|
+
seen.colors.add(key);
|
|
4601
|
+
findings.push({
|
|
4602
|
+
type: 'design-system-color',
|
|
4603
|
+
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
|
|
4604
|
+
ignoreValue: label,
|
|
4605
|
+
});
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
if (designSystem.hasRadii) {
|
|
4610
|
+
for (const token of browserRadiusTokens(style.borderRadius || '')) {
|
|
4611
|
+
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
|
|
4612
|
+
if (seen.radii.has(token)) continue;
|
|
4613
|
+
seen.radii.add(token);
|
|
4614
|
+
findings.push({
|
|
4615
|
+
type: 'design-system-radius',
|
|
4616
|
+
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
|
|
4617
|
+
ignoreValue: token,
|
|
4618
|
+
});
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
|
|
4622
|
+
return findings;
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
function decodeBrowserGoogleFamily(value) {
|
|
4626
|
+
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
|
|
4627
|
+
try {
|
|
4628
|
+
return decodeURIComponent(family);
|
|
4629
|
+
} catch {
|
|
4630
|
+
return family;
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
function checkBrowserDesignSystemSources(designSystem, seen) {
|
|
4635
|
+
if (!designSystem?.hasFonts) return [];
|
|
4636
|
+
const findings = [];
|
|
4637
|
+
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
|
|
4638
|
+
const href = link.getAttribute('href') || '';
|
|
4639
|
+
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
|
|
4640
|
+
const display = decodeBrowserGoogleFamily(match[1]);
|
|
4641
|
+
const font = normalizeBrowserFontName(display);
|
|
4642
|
+
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
|
|
4643
|
+
seen.fonts.add(font);
|
|
4644
|
+
findings.push({
|
|
4645
|
+
type: 'design-system-font',
|
|
4646
|
+
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
|
|
4647
|
+
ignoreValue: display,
|
|
4648
|
+
});
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
return findings;
|
|
4652
|
+
}
|
|
4653
|
+
|
|
4077
4654
|
function collectBrowserFindings() {
|
|
4078
4655
|
const groupMap = new Map();
|
|
4079
4656
|
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
|
4080
4657
|
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
|
4658
|
+
const designSystem = browserDesignSystemConfig();
|
|
4659
|
+
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
|
|
4081
4660
|
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
|
|
4082
4661
|
// real browser env (detector page, live overlay, extension) running every
|
|
4083
4662
|
// check is free, so we always surface them; the gating is purely a CLI
|
|
@@ -4108,6 +4687,7 @@ if (IS_BROWSER) {
|
|
|
4108
4687
|
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4109
4688
|
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4110
4689
|
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4690
|
+
...checkElementDesignSystemDOM(el, designSystem, designSeen),
|
|
4111
4691
|
].filter(f => _ruleOk(f.type));
|
|
4112
4692
|
|
|
4113
4693
|
addBrowserFindings(groupMap, el, findings);
|
|
@@ -4124,6 +4704,13 @@ if (IS_BROWSER) {
|
|
|
4124
4704
|
|
|
4125
4705
|
const pageLevelFindings = [];
|
|
4126
4706
|
|
|
4707
|
+
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
|
|
4708
|
+
.filter(f => _ruleOk(f.type));
|
|
4709
|
+
if (designSourceFindings.length > 0) {
|
|
4710
|
+
pageLevelFindings.push(...designSourceFindings);
|
|
4711
|
+
addBrowserFindings(groupMap, document.body, designSourceFindings);
|
|
4712
|
+
}
|
|
4713
|
+
|
|
4127
4714
|
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
|
|
4128
4715
|
if (typoFindings.length > 0) {
|
|
4129
4716
|
pageLevelFindings.push(...typoFindings);
|
|
@@ -4253,13 +4840,20 @@ if (IS_BROWSER) {
|
|
|
4253
4840
|
return true;
|
|
4254
4841
|
}
|
|
4255
4842
|
|
|
4256
|
-
function
|
|
4843
|
+
function scanResultMeta(options = {}) {
|
|
4844
|
+
const scanId = options.scanId;
|
|
4845
|
+
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
|
4846
|
+
return { scanId: String(scanId) };
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
function postSerializedFindings(groupMap, options = {}) {
|
|
4257
4850
|
if (!EXTENSION_MODE) return;
|
|
4258
4851
|
const allFindings = browserFindingsFromMap(groupMap);
|
|
4259
4852
|
window.postMessage({
|
|
4260
4853
|
source: 'impeccable-results',
|
|
4261
4854
|
findings: serializeFindings(allFindings),
|
|
4262
4855
|
count: allFindings.length,
|
|
4856
|
+
...scanResultMeta(options),
|
|
4263
4857
|
}, '*');
|
|
4264
4858
|
}
|
|
4265
4859
|
|
|
@@ -4313,7 +4907,7 @@ if (IS_BROWSER) {
|
|
|
4313
4907
|
rememberVisualContrastAnalysis(result);
|
|
4314
4908
|
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
|
4315
4909
|
if (added) {
|
|
4316
|
-
postSerializedFindings(groupMap);
|
|
4910
|
+
postSerializedFindings(groupMap, options);
|
|
4317
4911
|
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
|
4318
4912
|
detail: {
|
|
4319
4913
|
selector: result.selector,
|
|
@@ -4381,7 +4975,7 @@ if (IS_BROWSER) {
|
|
|
4381
4975
|
overlayIndex = 0;
|
|
4382
4976
|
}
|
|
4383
4977
|
|
|
4384
|
-
function renderBrowserFindings(collected) {
|
|
4978
|
+
function renderBrowserFindings(collected, options = {}) {
|
|
4385
4979
|
const { allFindings, pageLevelFindings } = collected;
|
|
4386
4980
|
|
|
4387
4981
|
for (const { el, findings } of allFindings) {
|
|
@@ -4401,6 +4995,7 @@ if (IS_BROWSER) {
|
|
|
4401
4995
|
source: 'impeccable-results',
|
|
4402
4996
|
findings: serializeFindings(allFindings),
|
|
4403
4997
|
count: allFindings.length,
|
|
4998
|
+
...scanResultMeta(options),
|
|
4404
4999
|
}, '*');
|
|
4405
5000
|
}
|
|
4406
5001
|
|
|
@@ -4415,11 +5010,11 @@ if (IS_BROWSER) {
|
|
|
4415
5010
|
clearOverlays();
|
|
4416
5011
|
const generation = scanGeneration;
|
|
4417
5012
|
const collected = collectBrowserFindings();
|
|
4418
|
-
const allFindings = renderBrowserFindings(collected);
|
|
5013
|
+
const allFindings = renderBrowserFindings(collected, options);
|
|
4419
5014
|
if (shouldRunVisualContrast(options)) {
|
|
4420
5015
|
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
|
4421
5016
|
.then(() => {
|
|
4422
|
-
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
|
|
5017
|
+
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
|
4423
5018
|
})
|
|
4424
5019
|
.catch(err => {
|
|
4425
5020
|
reportVisualContrastError(err);
|
|
@@ -4434,10 +5029,10 @@ if (IS_BROWSER) {
|
|
|
4434
5029
|
if (shouldRunVisualContrast(options)) {
|
|
4435
5030
|
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
|
4436
5031
|
if (generation !== scanGeneration) return [];
|
|
4437
|
-
return renderBrowserFindings(collected);
|
|
5032
|
+
return renderBrowserFindings(collected, options);
|
|
4438
5033
|
}
|
|
4439
5034
|
lastVisualContrastAnalyses = [];
|
|
4440
|
-
return renderBrowserFindings(collectBrowserFindings());
|
|
5035
|
+
return renderBrowserFindings(collectBrowserFindings(), options);
|
|
4441
5036
|
};
|
|
4442
5037
|
|
|
4443
5038
|
const detect = function(options = {}) {
|