@bastani/atomic 0.9.0-alpha.3 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/builtin/cursor/package.json +2 -2
  3. package/dist/builtin/intercom/package.json +1 -1
  4. package/dist/builtin/mcp/package.json +1 -1
  5. package/dist/builtin/subagents/package.json +1 -1
  6. package/dist/builtin/web-access/package.json +1 -1
  7. package/dist/builtin/workflows/CHANGELOG.md +17 -0
  8. package/dist/builtin/workflows/README.md +12 -12
  9. package/dist/builtin/workflows/builtin/goal-prompts.ts +8 -0
  10. package/dist/builtin/workflows/builtin/goal-runner.ts +96 -1
  11. package/dist/builtin/workflows/builtin/goal-types.ts +2 -0
  12. package/dist/builtin/workflows/builtin/goal.d.ts +3 -0
  13. package/dist/builtin/workflows/builtin/goal.ts +12 -1
  14. package/dist/builtin/workflows/builtin/index.d.ts +8 -8
  15. package/dist/builtin/workflows/builtin/open-claude-design-feedback.ts +359 -0
  16. package/dist/builtin/workflows/builtin/open-claude-design-phases.ts +254 -352
  17. package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +256 -414
  18. package/dist/builtin/workflows/builtin/open-claude-design-setup.ts +272 -0
  19. package/dist/builtin/workflows/builtin/open-claude-design-utils.ts +58 -68
  20. package/dist/builtin/workflows/builtin/open-claude-design.d.ts +5 -9
  21. package/dist/builtin/workflows/builtin/open-claude-design.ts +14 -26
  22. package/dist/builtin/workflows/package.json +1 -1
  23. package/dist/builtin/workflows/skills/impeccable/SKILL.md +14 -23
  24. package/dist/builtin/workflows/skills/impeccable/reference/brand.md +2 -2
  25. package/dist/builtin/workflows/skills/impeccable/reference/live.md +25 -4
  26. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +1 -1
  27. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +724 -29
  28. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +1 -1
  29. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +219 -7
  30. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +57 -11
  31. package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +750 -0
  32. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +648 -53
  33. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +7 -0
  34. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +29 -4
  35. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +44 -11
  36. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +29 -0
  37. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +27 -1
  38. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +1 -1
  39. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +29 -0
  40. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +401 -46
  41. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/inline-ignores.mjs +148 -0
  42. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/page.mjs +6 -6
  43. package/dist/builtin/workflows/skills/impeccable/scripts/{design-parser.mjs → lib/design-parser.mjs} +8 -1
  44. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +638 -0
  45. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-paths.mjs +128 -0
  46. package/dist/builtin/workflows/skills/impeccable/scripts/{is-generated.mjs → lib/is-generated.mjs} +2 -2
  47. package/dist/builtin/workflows/skills/impeccable/scripts/lib/target-args.mjs +42 -0
  48. package/dist/builtin/workflows/skills/impeccable/scripts/live/browser-script-parts.mjs +49 -0
  49. package/dist/builtin/workflows/skills/impeccable/scripts/{live-completion.mjs → live/completion.mjs} +1 -0
  50. package/dist/builtin/workflows/skills/impeccable/scripts/{live-event-validation.mjs → live/event-validation.mjs} +6 -5
  51. package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-apply.mjs +939 -0
  52. package/dist/builtin/workflows/skills/impeccable/scripts/live/manual-edit-routes.mjs +357 -0
  53. package/dist/builtin/workflows/skills/impeccable/scripts/{live-manual-edits-buffer.mjs → live/manual-edits-buffer.mjs} +1 -1
  54. package/dist/builtin/workflows/skills/impeccable/scripts/{live-session-store.mjs → live/session-store.mjs} +21 -3
  55. package/dist/builtin/workflows/skills/impeccable/scripts/live/svelte-component.mjs +835 -0
  56. package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +274 -0
  57. package/dist/builtin/workflows/skills/impeccable/scripts/live/ui-core.mjs +180 -0
  58. package/dist/builtin/workflows/skills/impeccable/scripts/live/vocabulary.mjs +36 -0
  59. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +185 -60
  60. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser-dom.js +146 -0
  61. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +3369 -1026
  62. package/dist/builtin/workflows/skills/impeccable/scripts/live-commit-manual-edits.mjs +2 -2
  63. package/dist/builtin/workflows/skills/impeccable/scripts/live-complete.mjs +2 -2
  64. package/dist/builtin/workflows/skills/impeccable/scripts/live-discard-manual-edits.mjs +1 -1
  65. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +133 -9
  66. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +42 -2
  67. package/dist/builtin/workflows/skills/impeccable/scripts/live-manual-edit-evidence.mjs +4 -4
  68. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +21 -15
  69. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +1 -1
  70. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +205 -1269
  71. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +2 -2
  72. package/dist/builtin/workflows/skills/impeccable/scripts/live-target.mjs +30 -0
  73. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +69 -26
  74. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +73 -22
  75. package/dist/core/atomic-guide-command.d.ts.map +1 -1
  76. package/dist/core/atomic-guide-command.js +5 -5
  77. package/dist/core/atomic-guide-command.js.map +1 -1
  78. package/docs/index.md +2 -2
  79. package/docs/quickstart.md +9 -9
  80. package/docs/workflows.md +42 -23
  81. package/package.json +2 -2
  82. package/dist/builtin/workflows/skills/impeccable/scripts/cleanup-deprecated.mjs +0 -284
  83. package/dist/builtin/workflows/skills/impeccable/scripts/impeccable-paths.mjs +0 -126
  84. /package/dist/builtin/workflows/skills/impeccable/scripts/{live-insert-ui.mjs → live/insert-ui.mjs} +0 -0
@@ -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*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
1088
- if (bounceRe.test(html)) {
1089
- findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
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
- return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
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
- if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
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.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
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 && !isTransparent(style.borderTopColor),
1905
- right: borderW.right > 0 && !isTransparent(style.borderRightColor),
1906
- bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
1907
- left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
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 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
1928
- const bgVisible = !isTransparent(style.backgroundColor);
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 = null;
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) childrenInsulate[s] = true;
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 sideBounded = borderVisible[side] || outlineVisible || bgVisible;
1978
- if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
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*="label" i], [class*="caption" i]');
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, so a full sentence ends
2681
- // up dominating the viewport. A punchy one- or two-word headline at the same
2682
- // size is a legitimate stylistic choice and must pass length, not size
2683
- // alone, is the tell.
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
- function checkOversizedH1({ tag, fontSize, headingText }) {
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
- return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
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
- return checkOversizedH1({ tag, fontSize, headingText });
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
- function shadowMaxBlurPx(boxShadow) {
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(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
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 checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
2729
- const maxBorder = Math.max(0, ...borderWidths);
2730
- const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
2731
- const blur = shadowMaxBlurPx(boxShadow);
2732
- if (hasThinBorder && blur >= 16) {
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 anyClip = clips(ox) || clips(oy) || clips(ov);
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 pos = (getStyle(child).position) || '';
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 postSerializedFindings(groupMap) {
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 = {}) {