@monoes/monodesign 1.2.20 → 1.2.23

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 (95) hide show
  1. package/README.md +2 -2
  2. package/cli/engine/cli/main.mjs +21 -1
  3. package/cli/engine/design-system-checks.mjs +381 -0
  4. package/cli/engine/design-system-parse.mjs +433 -0
  5. package/cli/engine/design-system.mjs +20 -736
  6. package/cli/engine/detect-antipatterns-browser.js +28 -5
  7. package/cli/engine/detect-antipatterns.mjs +1 -0
  8. package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
  9. package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
  10. package/cli/engine/engines/regex/detect-text.mjs +11 -523
  11. package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
  12. package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
  13. package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
  14. package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
  15. package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
  16. package/cli/engine/engines/static-html/detect-html.mjs +6 -1
  17. package/cli/engine/findings-group.mjs +94 -0
  18. package/cli/engine/fix/index.mjs +1 -26
  19. package/cli/engine/fix/unified-diff.mjs +107 -0
  20. package/cli/engine/rules/checks-background.mjs +17 -3
  21. package/cli/engine/rules/checks-element.mjs +2 -0
  22. package/cli/engine/rules/checks-pure.mjs +9 -2
  23. package/cli/lib/monodesign-config-consent.mjs +93 -0
  24. package/cli/lib/monodesign-config-core.mjs +37 -0
  25. package/cli/lib/monodesign-config-detection.mjs +133 -0
  26. package/cli/lib/monodesign-config-filter.mjs +198 -0
  27. package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
  28. package/cli/lib/monodesign-config.mjs +21 -623
  29. package/package.json +2 -2
  30. package/skill/scripts/context-fields.mjs +65 -0
  31. package/skill/scripts/context-resolve.mjs +425 -0
  32. package/skill/scripts/context-update.mjs +151 -0
  33. package/skill/scripts/context-utils.mjs +68 -0
  34. package/skill/scripts/context-workspace.mjs +245 -0
  35. package/skill/scripts/context.mjs +17 -891
  36. package/skill/scripts/hook-admin-config.mjs +152 -0
  37. package/skill/scripts/hook-admin-ignore.mjs +131 -0
  38. package/skill/scripts/hook-admin-manifest.mjs +292 -0
  39. package/skill/scripts/hook-admin.mjs +8 -541
  40. package/skill/scripts/hook-lib-cache.mjs +145 -0
  41. package/skill/scripts/hook-lib-config.mjs +292 -0
  42. package/skill/scripts/hook-lib-detector.mjs +72 -0
  43. package/skill/scripts/hook-lib-events.mjs +182 -0
  44. package/skill/scripts/hook-lib-findings.mjs +201 -0
  45. package/skill/scripts/hook-lib-ignore.mjs +205 -0
  46. package/skill/scripts/hook-lib-render.mjs +273 -0
  47. package/skill/scripts/hook-lib-run.mjs +281 -0
  48. package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
  49. package/skill/scripts/hook-lib.mjs +71 -1747
  50. package/skill/scripts/lib/design-parser-colors.mjs +182 -0
  51. package/skill/scripts/lib/design-parser-extract.mjs +333 -0
  52. package/skill/scripts/lib/design-parser-sections.mjs +214 -0
  53. package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
  54. package/skill/scripts/lib/design-parser.mjs +10 -783
  55. package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
  56. package/skill/scripts/live/manual-apply-compact.mjs +122 -0
  57. package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
  58. package/skill/scripts/live/manual-apply-log.mjs +179 -0
  59. package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
  60. package/skill/scripts/live/manual-apply.mjs +65 -640
  61. package/skill/scripts/live/svelte-component-accept.mjs +248 -0
  62. package/skill/scripts/live/svelte-component-css.mjs +230 -0
  63. package/skill/scripts/live/svelte-component-markup.mjs +104 -0
  64. package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
  65. package/skill/scripts/live/svelte-component-session.mjs +158 -0
  66. package/skill/scripts/live/svelte-component.mjs +32 -792
  67. package/skill/scripts/live-accept-mutate.mjs +152 -0
  68. package/skill/scripts/live-accept-parse.mjs +370 -0
  69. package/skill/scripts/live-accept-scrub.mjs +59 -0
  70. package/skill/scripts/live-accept-search.mjs +58 -0
  71. package/skill/scripts/live-accept.mjs +15 -631
  72. package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
  73. package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
  74. package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
  75. package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
  76. package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
  77. package/skill/scripts/live-commit-manual-edits.mjs +24 -881
  78. package/skill/scripts/live-copy-edit-agent.mjs +16 -551
  79. package/skill/scripts/live-copy-edit-compact.mjs +137 -0
  80. package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
  81. package/skill/scripts/live-copy-edit-runners.mjs +297 -0
  82. package/skill/scripts/live-inject-csp.mjs +132 -0
  83. package/skill/scripts/live-inject-files.mjs +229 -0
  84. package/skill/scripts/live-inject-tag.mjs +114 -0
  85. package/skill/scripts/live-inject.mjs +13 -440
  86. package/skill/scripts/live-server-poll.mjs +232 -0
  87. package/skill/scripts/live-server-routes.mjs +449 -0
  88. package/skill/scripts/live-server-state.mjs +361 -0
  89. package/skill/scripts/live-server.mjs +10 -946
  90. package/skill/scripts/live-wrap-css.mjs +132 -0
  91. package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
  92. package/skill/scripts/live-wrap-search.mjs +247 -0
  93. package/skill/scripts/live-wrap.mjs +21 -484
  94. package/skill/scripts/palette-seeds.mjs +401 -0
  95. package/skill/scripts/palette.mjs +1 -395
@@ -787,7 +787,7 @@ function isEmojiOnlyText(text) {
787
787
  }
788
788
 
789
789
  function checkColors(opts) {
790
- const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
790
+ const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList, isSvgText } = opts;
791
791
  if (SAFE_TAGS.has(tag)) {
792
792
  // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
793
793
  // exists to suppress contrast noise on inline links and unstyled controls,
@@ -835,7 +835,14 @@ function checkColors(opts) {
835
835
  // like `text-paper/60` on `bg-ink` sections are the FP pattern.
836
836
  const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
837
837
  if (!isAlphaFallbackFP) {
838
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
838
+ const snippet = `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}`;
839
+ // Static analysis can't composite translucent text/surfaces, see
840
+ // through gradients, or read SVG `fill`, so those ratios are guesses:
841
+ // report them as unverified rather than definite failures (#424).
842
+ const unverified = !DETECTOR_IS_BROWSER && (
843
+ (textColor.a ?? 1) < 1 || !effectiveBg || (effectiveBg.a ?? 1) < 1 || effectiveBg.uncertain || isSvgText
844
+ );
845
+ findings.push(unverified ? { id: 'low-contrast', snippet, unverified: true } : { id: 'low-contrast', snippet });
839
846
  }
840
847
  }
841
848
  }
@@ -1395,9 +1402,22 @@ function readOwnBackgroundColor(el, computedStyle) {
1395
1402
  return bg;
1396
1403
  }
1397
1404
 
1405
+ // Static mode marks the result `uncertain` when the colour is a guess: a
1406
+ // translucent layer was stacked on (or skipped over) the surface we found, a
1407
+ // body-level gradient was assumed white, or the walk never reached <body> and
1408
+ // fell back to the default white (a fragment's real surface is unknown).
1409
+ // Contrast measured against an uncertain surface is reported as unverified.
1410
+ function markUncertain(bg, uncertain) {
1411
+ if (!DETECTOR_IS_BROWSER && uncertain) bg.uncertain = true;
1412
+ return bg;
1413
+ }
1414
+
1398
1415
  function resolveBackground(el, win, customPropMap) {
1399
1416
  let current = el;
1417
+ let sawTranslucent = false;
1418
+ let sawBody = false;
1400
1419
  while (current && current.nodeType === 1) {
1420
+ if (current.tagName === 'BODY' || current.tagName === 'HTML') sawBody = true;
1401
1421
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1402
1422
  const bgImage = style.backgroundImage || '';
1403
1423
  const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
@@ -1429,8 +1449,9 @@ function resolveBackground(el, win, customPropMap) {
1429
1449
  }
1430
1450
 
1431
1451
  if (bg && bg.a > 0.1) {
1432
- if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
1452
+ if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return markUncertain(bg, sawTranslucent || bg.a < 1);
1433
1453
  }
1454
+ if (bg && bg.a > 0) sawTranslucent = true;
1434
1455
  // No solid bg-color at this level. If THIS level has a gradient/url
1435
1456
  // with no underlying solid color we can read:
1436
1457
  // • on body/html: assume white. Body-level gradients are almost
@@ -1445,13 +1466,13 @@ function resolveBackground(el, win, customPropMap) {
1445
1466
  // bgs worth checking against).
1446
1467
  if (hasGradientOrUrl) {
1447
1468
  if (current.tagName === 'BODY' || current.tagName === 'HTML') {
1448
- return { r: 255, g: 255, b: 255, a: 1 };
1469
+ return markUncertain({ r: 255, g: 255, b: 255, a: 1 }, true);
1449
1470
  }
1450
1471
  return null;
1451
1472
  }
1452
1473
  current = current.parentElement;
1453
1474
  }
1454
- return { r: 255, g: 255, b: 255 };
1475
+ return markUncertain({ r: 255, g: 255, b: 255 }, sawTranslucent || !sawBody);
1455
1476
  }
1456
1477
 
1457
1478
  // Walk parents looking for a gradient background and return its color stops.
@@ -2563,6 +2584,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
2563
2584
  bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
2564
2585
  bgImage: style.backgroundImage || '',
2565
2586
  classList: el.getAttribute?.('class') || el.className || '',
2587
+ // SVG text paints with `fill`, not `color`, so its CSS colour says nothing.
2588
+ isSvgText: tag === 'text' || tag === 'tspan' || tag === 'textpath',
2566
2589
  });
2567
2590
  }
2568
2591
 
@@ -31,6 +31,7 @@ export {
31
31
  collectStaticDesignSystemFindings,
32
32
  } from './design-system.mjs';
33
33
  export { detectHtml } from './engines/static-html/detect-html.mjs';
34
+ export { groupFindings } from './findings-group.mjs';
34
35
  export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
35
36
  export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
36
37
  export {
@@ -0,0 +1,371 @@
1
+ // Regex fallback (non-HTML files: CSS, JSX, TSX, etc.): line/content
2
+ // predicates plus the REGEX_MATCHERS and REGEX_ANALYZERS rule tables. Split
3
+ // out of detect-text.mjs (file-size sweep — pure move, no behaviour change).
4
+
5
+ import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
6
+ import { isNeutralColor } from '../../shared/color.mjs';
7
+ import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
8
+ import { isFullPage } from '../../shared/page.mjs';
9
+ import { finding } from '../../findings.mjs';
10
+
11
+ const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
12
+ const hasBorderRadius = (line) => /border-radius/i.test(line);
13
+ const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
14
+
15
+ /** Strip HTML to plain text — drops script/style/comments/tags so
16
+ * content-text analyzers don't false-positive on code or CSS. */
17
+ function stripHtmlToText(html) {
18
+ return html
19
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
20
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
21
+ .replace(/<!--[\s\S]*?-->/g, ' ')
22
+ .replace(/<[^>]+>/g, ' ')
23
+ .replace(/\s+/g, ' ');
24
+ }
25
+
26
+ const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
27
+
28
+ function extFromFilePath(filePath) {
29
+ return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
30
+ }
31
+
32
+ function shouldRunPageAnalyzers(content, filePath) {
33
+ if (!isFullPage(content)) return false;
34
+ const ext = extFromFilePath(filePath);
35
+ return !ext || PAGE_ANALYZER_EXTS.has(ext);
36
+ }
37
+
38
+ function firstOverusedGoogleFont(text) {
39
+ return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
40
+ }
41
+
42
+ function isNeutralBorderColor(str) {
43
+ const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
44
+ if (!m) return false;
45
+ const c = m[1].toLowerCase();
46
+ if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
47
+ if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
48
+ const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
49
+ if (hex) {
50
+ const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
51
+ return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
52
+ }
53
+ const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
54
+ if (shex) {
55
+ const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
56
+ return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
57
+ }
58
+ return false;
59
+ }
60
+
61
+ const REGEX_MATCHERS = [
62
+ // --- Side-tab ---
63
+ { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
64
+ test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
65
+ fmt: (m) => m[0] },
66
+ { id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
67
+ test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
68
+ fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
69
+ { id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
70
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
71
+ fmt: (m) => m[0] },
72
+ { id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
73
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
74
+ fmt: (m) => m[0] },
75
+ { id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
76
+ test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
77
+ fmt: (m) => m[0] },
78
+ { id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
79
+ test: (m) => +m[1] >= 3,
80
+ fmt: (m) => m[0] },
81
+ // --- Border accent on rounded ---
82
+ { id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
83
+ test: (m, line) => hasRounded(line) && +m[1] >= 1,
84
+ fmt: (m) => m[0] },
85
+ { id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
86
+ test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
87
+ fmt: (m) => m[0] },
88
+ // --- Overused font ---
89
+ { id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
90
+ test: () => true,
91
+ fmt: (m) => m[0] },
92
+ { id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
93
+ test: (m) => {
94
+ m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
95
+ return Boolean(m.overusedGoogleFont);
96
+ },
97
+ fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
98
+ // --- Gradient text ---
99
+ { id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
100
+ test: (_m, line) => /gradient/i.test(line),
101
+ fmt: () => 'background-clip: text + gradient' },
102
+ // --- Gradient text (Tailwind) ---
103
+ { id: 'gradient-text', regex: /\bbg-clip-text\b/g,
104
+ test: (_m, line) => /\bbg-gradient-to-/i.test(line),
105
+ fmt: () => 'bg-clip-text + bg-gradient' },
106
+ // --- Tailwind gray on colored bg ---
107
+ { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
108
+ test: (_m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
109
+ fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
110
+ // --- Tailwind AI palette ---
111
+ { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
112
+ test: (_m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
113
+ fmt: (m) => `${m[0]} on heading` },
114
+ { id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
115
+ test: (_m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
116
+ fmt: (m) => `${m[0]} gradient` },
117
+ // --- Bounce/elastic easing ---
118
+ { id: 'bounce-easing', regex: /\banimate-bounce\b/g,
119
+ test: () => true,
120
+ fmt: () => 'animate-bounce (Tailwind)' },
121
+ { id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
122
+ test: () => true,
123
+ fmt: (m) => {
124
+ const token = m[1]
125
+ .split(/[,\s]+/)
126
+ .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
127
+ return `animation: ${token || m[1].trim()}`;
128
+ } },
129
+ { id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
130
+ test: (m) => {
131
+ const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
132
+ return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
133
+ },
134
+ fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
135
+ // --- Layout property transition ---
136
+ { id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
137
+ test: (m) => {
138
+ const val = m[1].toLowerCase();
139
+ if (/\ball\b/.test(val)) return false;
140
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
141
+ },
142
+ fmt: (m) => {
143
+ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
144
+ return `transition: ${found ? found.join(', ') : m[1].trim()}`;
145
+ } },
146
+ { id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
147
+ test: (m) => {
148
+ const val = m[1].toLowerCase();
149
+ if (/\ball\b/.test(val)) return false;
150
+ return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
151
+ },
152
+ fmt: (m) => {
153
+ const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
154
+ return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
155
+ } },
156
+ // --- Broken image: src="" or src="#" or src=" " ---
157
+ { id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
158
+ test: () => true,
159
+ fmt: (m) => m[0].slice(0, 100) },
160
+ // --- Broken image: <img> with no src attribute at all ---
161
+ { id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
162
+ test: (m) => !/\bsrc\s*=/i.test(m[0]),
163
+ fmt: (m) => m[0].slice(0, 100) },
164
+ // --- Image missing dimensions (CLS): <img> without width+height and
165
+ // without a CSS aspect-ratio or explicit height. Covers HTML (width="…")
166
+ // and JSX (width={…}) attribute forms. ---
167
+ { id: 'image-missing-dimensions', regex: /<img\b[^>]*>/gi,
168
+ test: (m) => {
169
+ const tag = m[0];
170
+ const hasHeight = /(?:^|[\s{;"'])height\s*[=:]/i.test(tag);
171
+ const hasAspect = /aspect-?ratio/i.test(tag);
172
+ return !hasHeight && !hasAspect;
173
+ },
174
+ fmt: (m) => m[0].slice(0, 100) },
175
+ ];
176
+
177
+ const REGEX_ANALYZERS = [
178
+ // Single font
179
+ (content, filePath) => {
180
+ const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
181
+ const fonts = new Set();
182
+ let m;
183
+ while ((m = fontFamilyRe.exec(content)) !== null) {
184
+ for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
185
+ if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
186
+ }
187
+ }
188
+ for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
189
+ if (fonts.size !== 1 || content.split('\n').length < 20) return [];
190
+ const name = [...fonts][0];
191
+ const lines = content.split('\n');
192
+ let line = 1;
193
+ for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
194
+ return [finding('single-font', filePath, `only font used is ${name}`, line)];
195
+ },
196
+ // Flat type hierarchy
197
+ (content, filePath) => {
198
+ const sizes = new Set();
199
+ const REM = 16;
200
+ let m;
201
+ const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
202
+ while ((m = sizeRe.exec(content)) !== null) {
203
+ const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
204
+ if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
205
+ }
206
+ const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
207
+ while ((m = clampRe.exec(content)) !== null) {
208
+ sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
209
+ sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
210
+ }
211
+ const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
212
+ for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
213
+ if (sizes.size < 3) return [];
214
+ const sorted = [...sizes].sort((a, b) => a - b);
215
+ const ratio = sorted[sorted.length - 1] / sorted[0];
216
+ if (ratio >= 2.0) return [];
217
+ const lines = content.split('\n');
218
+ let line = 1;
219
+ for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
220
+ return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => `${s}px`).join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
221
+ },
222
+ // Monotonous spacing (regex)
223
+ (content, filePath) => {
224
+ const vals = [];
225
+ let m;
226
+ const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
227
+ while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
228
+ const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
229
+ while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
230
+ const gapRe = /gap\s*:\s*(\d+)px/gi;
231
+ while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
232
+ const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
233
+ while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
234
+ const rounded = vals.map(v => Math.round(v / 4) * 4);
235
+ if (rounded.length < 10) return [];
236
+ const counts = {};
237
+ for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
238
+ const maxCount = Math.max(...Object.values(counts));
239
+ const pct = maxCount / rounded.length;
240
+ const unique = [...new Set(rounded)].filter(v => v > 0);
241
+ if (pct <= 0.6 || unique.length > 3) return [];
242
+ const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
243
+ return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
244
+ },
245
+ // Em-dash overuse: 5+ em-dashes or "--" in body text content
246
+ // (occasional em-dash use in prose is fine; the pattern fires only
247
+ // when count crosses into AI-cadence territory).
248
+ (content, filePath) => {
249
+ const text = stripHtmlToText(content);
250
+ let count = 0;
251
+ const re = /[—]|--(?=\S)/g;
252
+ while (re.exec(text) !== null) count++;
253
+ if (count < 5) return [];
254
+ return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
255
+ },
256
+ // Marketing buzzwords: SaaS phrase list
257
+ (content, filePath) => {
258
+ const text = stripHtmlToText(content);
259
+ const lower = text.toLowerCase();
260
+ const BUZZWORDS = [
261
+ 'streamline your', 'empower your', 'supercharge your',
262
+ 'unleash your', 'unleash the power', 'leverage the power',
263
+ 'built for the modern', 'trusted by leading', 'trusted by the world',
264
+ 'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
265
+ 'next-generation', 'cutting-edge', 'transform your business',
266
+ 'revolutionize', 'game-changer', 'game changing',
267
+ 'mission-critical', 'best of breed', 'future-proof', 'future proof',
268
+ 'seamless experience', 'seamlessly integrate',
269
+ 'drive engagement', 'drive growth', 'drive results',
270
+ 'harness the power',
271
+ ];
272
+ let count = 0;
273
+ let firstSample = '';
274
+ for (const phrase of BUZZWORDS) {
275
+ let from = 0;
276
+ while (true) {
277
+ const idx = lower.indexOf(phrase, from);
278
+ if (idx === -1) break;
279
+ count++;
280
+ if (!firstSample) {
281
+ firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
282
+ }
283
+ from = idx + phrase.length;
284
+ }
285
+ }
286
+ if (count === 0) return [];
287
+ return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
288
+ },
289
+ // Numbered section markers (01 / 02 / 03 ...)
290
+ (content, filePath) => {
291
+ const text = stripHtmlToText(content);
292
+ const re = /\b(0[1-9]|1[0-2])\b/g;
293
+ const seen = new Set();
294
+ let m;
295
+ while ((m = re.exec(text)) !== null) seen.add(m[1]);
296
+ if (seen.size < 3) return [];
297
+ const sorted = [...seen].sort();
298
+ let sequential = 0;
299
+ for (let i = 1; i < sorted.length; i++) {
300
+ if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
301
+ }
302
+ if (sequential < 2) return [];
303
+ return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
304
+ },
305
+ // Aphoristic cadence: manufactured-contrast + short-rebuttal
306
+ (content, filePath) => {
307
+ const text = stripHtmlToText(content);
308
+ const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
309
+ const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
310
+ let count = 0;
311
+ let firstSample = '';
312
+ let m;
313
+ NOT_A_RE.lastIndex = 0;
314
+ while ((m = NOT_A_RE.exec(text)) !== null) {
315
+ count++;
316
+ if (!firstSample) firstSample = m[0].trim().slice(0, 80);
317
+ }
318
+ SHORT_REBUTTAL_RE.lastIndex = 0;
319
+ while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
320
+ count++;
321
+ if (!firstSample) firstSample = m[0].trim().slice(0, 80);
322
+ }
323
+ if (count < 3) return [];
324
+ return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
325
+ },
326
+ // Dark glow (page-level: dark bg + colored box-shadow with blur)
327
+ (content, filePath) => {
328
+ // Check if page has a dark background
329
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
330
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
331
+ const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
332
+ if (!hasDarkBg) return [];
333
+
334
+ // Check for colored box-shadow with blur > 4px
335
+ const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
336
+ let m;
337
+ while ((m = shadowRe.exec(content)) !== null) {
338
+ const val = m[1];
339
+ const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
340
+ if (!colorMatch) continue;
341
+ const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
342
+ if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
343
+ // Check blur: look for pattern like "0 0 20px" (third number > 4)
344
+ const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
345
+ if (pxVals.length >= 3 && pxVals[2] > 4) {
346
+ const lines = content.substring(0, m.index).split('\n');
347
+ return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
348
+ }
349
+ }
350
+ return [];
351
+ },
352
+ ];
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Style block extraction (Vue/Svelte <style> blocks)
356
+ // ---------------------------------------------------------------------------
357
+
358
+
359
+ export {
360
+ hasRounded,
361
+ hasBorderRadius,
362
+ isSafeElement,
363
+ stripHtmlToText,
364
+ PAGE_ANALYZER_EXTS,
365
+ extFromFilePath,
366
+ shouldRunPageAnalyzers,
367
+ firstOverusedGoogleFont,
368
+ isNeutralBorderColor,
369
+ REGEX_MATCHERS,
370
+ REGEX_ANALYZERS,
371
+ };
@@ -0,0 +1,186 @@
1
+ // Running the regex matcher/analyzer tables against source text: style-block
2
+ // and CSS-in-JS extraction, and the three run* entry points (regex matchers,
3
+ // text-content analyzers, stylesheet analyzers). Split out of detect-text.mjs
4
+ // (file-size sweep — pure move, no behaviour change).
5
+
6
+ import { isFullPage } from '../../shared/page.mjs';
7
+ import { finding } from '../../findings.mjs';
8
+ import { profileFindings } from '../../profile/profiler.mjs';
9
+ import {
10
+ checkFocusVisible,
11
+ checkHoverOnlyAffordance,
12
+ checkDarkSchemeContrast,
13
+ } from '../../rules/checks.mjs';
14
+ import { REGEX_MATCHERS, REGEX_ANALYZERS, shouldRunPageAnalyzers, extFromFilePath } from './detect-text-matchers.mjs';
15
+
16
+ function extractStyleBlocks(content, ext) {
17
+ ext = ext.toLowerCase();
18
+ if (ext !== '.vue' && ext !== '.svelte') return [];
19
+ const blocks = [];
20
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
21
+ let m;
22
+ while ((m = re.exec(content)) !== null) {
23
+ const before = content.substring(0, m.index);
24
+ const startLine = before.split('\n').length + 1;
25
+ blocks.push({ content: m[1], startLine });
26
+ }
27
+ return blocks;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // CSS-in-JS extraction (styled-components, emotion)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
35
+
36
+ function extractCSSinJS(content, ext) {
37
+ ext = ext.toLowerCase();
38
+ if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
39
+ const blocks = [];
40
+ const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
41
+ let m;
42
+ while ((m = re.exec(content)) !== null) {
43
+ const before = content.substring(0, m.index);
44
+ const startLine = before.split('\n').length;
45
+ blocks.push({ content: m[1], startLine });
46
+ }
47
+ return blocks;
48
+ }
49
+
50
+ function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
51
+ const { profile, phase = 'regex-matchers' } = options || {};
52
+ const findings = [];
53
+ if (!profile) {
54
+ for (const matcher of REGEX_MATCHERS) {
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const line = lines[i];
57
+ matcher.regex.lastIndex = 0;
58
+ let m;
59
+ while ((m = matcher.regex.exec(line)) !== null) {
60
+ // For extracted blocks, use nearby lines as context for multi-line CSS patterns
61
+ const context = blockContext
62
+ ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
63
+ : line;
64
+ if (matcher.test(m, context)) {
65
+ findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
66
+ }
67
+ }
68
+ }
69
+ }
70
+ return findings;
71
+ }
72
+
73
+ for (const matcher of REGEX_MATCHERS) {
74
+ const matcherFindings = profileFindings(profile, {
75
+ engine: 'regex',
76
+ phase,
77
+ ruleId: matcher.id,
78
+ target: filePath,
79
+ }, () => {
80
+ const matches = [];
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ matcher.regex.lastIndex = 0;
84
+ let m;
85
+ while ((m = matcher.regex.exec(line)) !== null) {
86
+ // For extracted blocks, use nearby lines as context for multi-line CSS patterns
87
+ const context = blockContext
88
+ ? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
89
+ : line;
90
+ if (matcher.test(m, context)) {
91
+ matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
92
+ }
93
+ }
94
+ }
95
+ return matches;
96
+ });
97
+ findings.push(...matcherFindings);
98
+ }
99
+ return findings;
100
+ }
101
+
102
+ /** Page-level analyzers that scan rendered text content (em-dash use,
103
+ * buzzword phrases, numbered section markers, aphoristic cadence).
104
+ * These are detector-agnostic — they work on any HTML/text source
105
+ * and don't need a parsed DOM. Exported so detectHtml can call them
106
+ * for `.html` files (which otherwise skip the regex engine). */
107
+ const TEXT_CONTENT_ANALYZER_IDS = [
108
+ 'em-dash-overuse',
109
+ 'marketing-buzzword',
110
+ 'numbered-section-markers',
111
+ 'aphoristic-cadence',
112
+ ];
113
+
114
+ function runTextContentAnalyzers(content, filePath, options = {}) {
115
+ const profile = options?.profile;
116
+ if (!shouldRunPageAnalyzers(content, filePath)) return [];
117
+ // The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
118
+ const findings = [];
119
+ for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
120
+ const analyzer = REGEX_ANALYZERS[3 + i];
121
+ const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
122
+ findings.push(...profileFindings(profile, {
123
+ engine: 'regex',
124
+ phase: 'text-content',
125
+ ruleId,
126
+ target: filePath,
127
+ }, () => analyzer(content, filePath)));
128
+ }
129
+ return findings;
130
+ }
131
+
132
+ // Extract raw CSS text from a source file for the stylesheet-level analyzers
133
+ // (focus-visible, hover-only, dark-scheme). CSS-like files are CSS wholesale;
134
+ // everything else contributes only its <style> block contents so tag markup
135
+ // and inline styles don't confuse the flat CSS parser.
136
+ const CSS_LIKE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
137
+ function extractStylesheetText(content, ext) {
138
+ if (CSS_LIKE_EXTS.has(ext)) return content;
139
+ const blocks = [];
140
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
141
+ let m;
142
+ while ((m = re.exec(content)) !== null) blocks.push(m[1]);
143
+ return blocks.join('\n');
144
+ }
145
+
146
+ const STYLESHEET_ANALYZERS = [
147
+ { id: 'missing-focus-visible', run: checkFocusVisible },
148
+ { id: 'hover-only-affordance', run: checkHoverOnlyAffordance },
149
+ { id: 'dark-scheme-contrast-blindspot', run: checkDarkSchemeContrast },
150
+ ];
151
+
152
+ // Run the stylesheet-level analyzers over a source file's CSS. Applies to
153
+ // CSS-like sources and to full HTML pages (which carry <style> blocks).
154
+ function runStylesheetAnalyzers(content, filePath, options = {}) {
155
+ const profile = options?.profile;
156
+ const ext = extFromFilePath(filePath);
157
+ const isCssLike = CSS_LIKE_EXTS.has(ext);
158
+ if (!isCssLike && !isFullPage(content)) return [];
159
+ const cssText = extractStylesheetText(content, ext);
160
+ if (!cssText.trim()) return [];
161
+ const findings = [];
162
+ for (const analyzer of STYLESHEET_ANALYZERS) {
163
+ const hits = profileFindings(profile, {
164
+ engine: 'regex',
165
+ phase: 'stylesheet',
166
+ ruleId: analyzer.id,
167
+ target: filePath,
168
+ }, () => analyzer.run(cssText));
169
+ for (const h of hits) findings.push(finding(h.id, filePath, h.snippet));
170
+ }
171
+ return findings;
172
+ }
173
+
174
+
175
+ export {
176
+ extractStyleBlocks,
177
+ CSS_IN_JS_EXTENSIONS,
178
+ extractCSSinJS,
179
+ runRegexMatchers,
180
+ TEXT_CONTENT_ANALYZER_IDS,
181
+ runTextContentAnalyzers,
182
+ CSS_LIKE_EXTS,
183
+ extractStylesheetText,
184
+ STYLESHEET_ANALYZERS,
185
+ runStylesheetAnalyzers,
186
+ };