@monoes/monodesign 1.2.22 → 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.
@@ -18,6 +18,7 @@ import {
18
18
  isPortListening,
19
19
  walkDir,
20
20
  } from '../node/file-system.mjs';
21
+ import { formatGroupedFindings } from '../findings-group.mjs';
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Output formatting
@@ -92,6 +93,12 @@ Scan files or URLs for UI anti-patterns and design quality issues.
92
93
  Options:
93
94
  --json Output results as JSON
94
95
  --quiet In text mode, only print the final findings count
96
+ --no-group In text mode, list every occurrence instead of one line
97
+ per unique (rule, snippet) with a count
98
+ --include-unverified
99
+ Also report contrast checks the static analyser cannot
100
+ verify (translucent colours, overlays, gradients, SVG
101
+ text); hidden by default
95
102
  --gpt Also report GPT-specific provider tells (off by default)
96
103
  --gemini Also report Gemini-specific provider tells (off by default)
97
104
  --scope <name> Only report rules in the given design domain
@@ -138,6 +145,8 @@ async function detectCli() {
138
145
  const jsonMode = args.includes('--json');
139
146
  const quietMode = args.includes('--quiet');
140
147
  const helpMode = args.includes('--help');
148
+ const groupMode = !args.includes('--no-group');
149
+ const includeUnverified = args.includes('--include-unverified');
141
150
  // --fast (regex-only) is deprecated: since the jsdom removal, the static
142
151
  // HTML/CSS analysis is fast and covers every rule, so the regex-only path
143
152
  // only loses coverage for no real speed win. Accept the flag for back-compat
@@ -187,7 +196,8 @@ async function detectCli() {
187
196
  // apply by default. `--no-config` (raw scan) and the dedicated
188
197
  // `--no-inline-ignores` both turn them off.
189
198
  const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
190
- const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
199
+ // Always collect unverified contrast checks so the hidden count can be shown.
200
+ const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled, includeUnverified: true };
191
201
  if (designSystem) scanOptions.designSystem = designSystem;
192
202
  const targets = args.filter(a => !a.startsWith('--'));
193
203
 
@@ -307,10 +317,20 @@ async function detectCli() {
307
317
 
308
318
  allFindings = filterDetectionFindings(allFindings, detectionConfig);
309
319
  allFindings = filterByScopes(allFindings, scopes);
320
+ const unverifiedCount = includeUnverified ? 0 : allFindings.filter(f => f.unverified).length;
321
+ if (!includeUnverified) allFindings = allFindings.filter(f => !f.unverified);
322
+ if (unverifiedCount > 0 && !jsonMode && !quietMode) {
323
+ process.stderr.write(
324
+ `${unverifiedCount} contrast check${unverifiedCount === 1 ? '' : 's'} could not be verified statically ` +
325
+ `(translucent colours, overlays, gradients or SVG text) and ${unverifiedCount === 1 ? 'is' : 'are'} hidden. ` +
326
+ `Pass --include-unverified to list them, or scan the page URL for a browser-rendered check.\n`,
327
+ );
328
+ }
310
329
 
311
330
  if (allFindings.length > 0) {
312
331
  if (jsonMode) process.stdout.write(`${formatFindings(allFindings, true)}\n`);
313
332
  else if (quietMode) process.stderr.write(`${formatFindingSummary(allFindings.length)}\n`);
333
+ else if (groupMode) process.stderr.write(`${formatGroupedFindings(allFindings)}\n`);
314
334
  else process.stderr.write(`${formatFindings(allFindings, false)}\n`);
315
335
  process.exit(2);
316
336
  }
@@ -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 {
@@ -176,7 +176,12 @@ async function detectHtml(filePath, options = {}) {
176
176
  const tag = el.tagName.toLowerCase();
177
177
  const style = window.getComputedStyle(el);
178
178
  for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
179
- findings.push(finding(f.id, filePath, f.snippet));
179
+ // Contrast the static cascade can't resolve (translucent colours,
180
+ // overlays, gradients) is not a definite failure: callers opt in (#424).
181
+ if (f.unverified && !options?.includeUnverified) continue;
182
+ const item = finding(f.id, filePath, f.snippet);
183
+ if (f.unverified) item.unverified = true;
184
+ findings.push(item);
180
185
  }
181
186
  }
182
187
  }
@@ -0,0 +1,94 @@
1
+ // Collapse repeated findings into one entry per (rule, snippet). A single
2
+ // dark-theme token can fail contrast on hundreds of elements; reporting each
3
+ // occurrence floods agents and humans with the same line (#424).
4
+
5
+ import path from 'node:path';
6
+
7
+ const SEVERITY_RANK = { error: 3, warning: 2, advisory: 1, info: 0 };
8
+
9
+ function severityRank(severity) {
10
+ return SEVERITY_RANK[severity] ?? SEVERITY_RANK.warning;
11
+ }
12
+
13
+ function normalizeSnippet(snippet) {
14
+ return String(snippet ?? '').replace(/\s+/g, ' ').trim();
15
+ }
16
+
17
+ /**
18
+ * Group findings by (antipattern, normalized snippet).
19
+ * Returns groups sorted by severity, then occurrence count (both descending),
20
+ * each carrying the first `maxLocations` locations as examples.
21
+ */
22
+ function groupFindings(findings, { maxLocations = 3 } = {}) {
23
+ const groups = new Map();
24
+ for (const f of findings) {
25
+ const snippet = normalizeSnippet(f.snippet);
26
+ const key = `${f.antipattern}\u0000${snippet}`;
27
+ let g = groups.get(key);
28
+ if (!g) {
29
+ g = {
30
+ antipattern: f.antipattern,
31
+ name: f.name,
32
+ severity: f.severity,
33
+ description: f.description,
34
+ snippet,
35
+ count: 0,
36
+ files: new Set(),
37
+ seen: new Set(),
38
+ locations: [],
39
+ };
40
+ groups.set(key, g);
41
+ }
42
+ g.count += 1;
43
+ g.files.add(f.file);
44
+ // Static-HTML findings carry no line, so dedupe locations: one entry per
45
+ // distinct file:line rather than the same file repeated.
46
+ const loc = f.line ? { file: f.file, line: f.line } : { file: f.file };
47
+ if (f.importedBy?.length) loc.importedBy = f.importedBy;
48
+ const locKey = `${loc.file}:${loc.line ?? ''}`;
49
+ if (!g.seen.has(locKey) && g.locations.length < maxLocations) {
50
+ g.seen.add(locKey);
51
+ g.locations.push(loc);
52
+ }
53
+ }
54
+ // Keep a rule's groups together: order rules by severity then total
55
+ // occurrences, and groups within a rule by their own count.
56
+ const ruleTotals = new Map();
57
+ for (const g of groups.values()) ruleTotals.set(g.antipattern, (ruleTotals.get(g.antipattern) ?? 0) + g.count);
58
+ return [...groups.values()]
59
+ .map(({ files, seen, ...g }) => ({ ...g, fileCount: files.size }))
60
+ .sort((a, b) =>
61
+ severityRank(b.severity) - severityRank(a.severity)
62
+ || ruleTotals.get(b.antipattern) - ruleTotals.get(a.antipattern)
63
+ || a.antipattern.localeCompare(b.antipattern)
64
+ || b.count - a.count);
65
+ }
66
+
67
+ function formatLocation(loc, cwd) {
68
+ const rel = cwd && path.isAbsolute(loc.file) ? path.relative(cwd, loc.file) || loc.file : loc.file;
69
+ const where = loc.line ? `${rel}:${loc.line}` : rel;
70
+ return loc.importedBy?.length ? `${where} (imported by ${loc.importedBy.join(', ')})` : where;
71
+ }
72
+
73
+ /** Human-readable grouped report: one line per unique finding. */
74
+ function formatGroupedFindings(findings, cwd = process.cwd()) {
75
+ const groups = groupFindings(findings);
76
+ const out = [];
77
+ let lastRule = null;
78
+ for (const g of groups) {
79
+ if (g.antipattern !== lastRule) {
80
+ out.push(`\n[${g.antipattern}] ${g.name} (${g.severity})`);
81
+ out.push(` → ${g.description}`);
82
+ lastRule = g.antipattern;
83
+ }
84
+ const where = g.locations.map(loc => formatLocation(loc, cwd)).join(', ');
85
+ const more = g.fileCount > g.locations.length ? `, +${g.fileCount - g.locations.length} more file(s)` : '';
86
+ out.push(` ×${g.count} ${g.snippet}`);
87
+ out.push(` at ${where}${more}`);
88
+ }
89
+ const unique = groups.length;
90
+ out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found (${unique} unique). Use --no-group to list every occurrence.`);
91
+ return out.join('\n');
92
+ }
93
+
94
+ export { groupFindings, formatGroupedFindings, severityRank };
@@ -23,6 +23,7 @@ import {
23
23
  } from '../../lib/monodesign-config.mjs';
24
24
  import { HTML_EXTENSIONS, walkDir } from '../node/file-system.mjs';
25
25
  import { UNFIXABLE_REASONS, getFixer, fixableRuleIds, FIXERS } from './fixers.mjs';
26
+ import { unifiedDiff } from './unified-diff.mjs';
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Edit application
@@ -57,32 +58,6 @@ function writeFileAtomic(filePath, content) {
57
58
  fs.renameSync(tmp, filePath);
58
59
  }
59
60
 
60
- // ---------------------------------------------------------------------------
61
- // Unified diff (minimal single-hunk implementation for --dry-run)
62
- // ---------------------------------------------------------------------------
63
-
64
- function unifiedDiff(oldText, newText, label) {
65
- if (oldText === newText) return '';
66
- const a = oldText.split('\n');
67
- const b = newText.split('\n');
68
- let p = 0;
69
- while (p < a.length && p < b.length && a[p] === b[p]) p++;
70
- let s = 0;
71
- while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
72
- const aEnd = a.length - s;
73
- const bEnd = b.length - s;
74
- const ctxStart = Math.max(0, p - 3);
75
- const ctxEnd = Math.min(a.length, aEnd + 3);
76
- const oldLen = ctxEnd - ctxStart;
77
- const newLen = (p - ctxStart) + (bEnd - p) + (ctxEnd - aEnd);
78
- const lines = [`--- a/${label}`, `+++ b/${label}`, `@@ -${ctxStart + 1},${oldLen} +${ctxStart + 1},${newLen} @@`];
79
- for (let i = ctxStart; i < p; i++) lines.push(` ${a[i]}`);
80
- for (let i = p; i < aEnd; i++) lines.push(`-${a[i]}`);
81
- for (let i = p; i < bEnd; i++) lines.push(`+${b[i]}`);
82
- for (let i = aEnd; i < ctxEnd; i++) lines.push(` ${a[i]}`);
83
- return lines.join('\n');
84
- }
85
-
86
61
  // ---------------------------------------------------------------------------
87
62
  // Linked stylesheet resolution (HTML findings whose declaration lives in CSS)
88
63
  // ---------------------------------------------------------------------------
@@ -0,0 +1,107 @@
1
+ // Line-level unified diff for `fix --dry-run`. The old single-hunk version
2
+ // spanned the first to the last changed line, so 30 one-line edits spread
3
+ // across a big file printed nearly the whole file (#424). This uses Myers'
4
+ // O((N+M)D) algorithm — D (edited lines) is small for codemods — and emits
5
+ // one hunk per cluster of changes with 3 lines of context.
6
+
7
+ const CONTEXT = 3;
8
+ // Beyond this many edited lines the trace gets large; fall back to replacing
9
+ // the changed middle wholesale (still trimmed of the shared prefix/suffix).
10
+ const MAX_EDITS = 2000;
11
+
12
+ // Returns an edit script: [{ op: ' ' | '-' | '+', a, b }] where a/b index the
13
+ // old/new line arrays; null when the diff exceeds MAX_EDITS.
14
+ function myersDiff(a, b) {
15
+ const n = a.length;
16
+ const m = b.length;
17
+ const max = n + m;
18
+ const offset = max;
19
+ const v = new Array(2 * max + 2).fill(0);
20
+ const trace = [];
21
+ let found = false;
22
+ for (let d = 0; d <= max && !found; d++) {
23
+ if (d > MAX_EDITS) return null;
24
+ // Only diagonals -d..d can be read back for this step: O(D^2) memory.
25
+ trace.push(v.slice(offset - d, offset + d + 1));
26
+ for (let k = -d; k <= d; k += 2) {
27
+ let x = (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]))
28
+ ? v[offset + k + 1]
29
+ : v[offset + k - 1] + 1;
30
+ let y = x - k;
31
+ while (x < n && y < m && a[x] === b[y]) { x++; y++; }
32
+ v[offset + k] = x;
33
+ if (x >= n && y >= m) { found = true; break; }
34
+ }
35
+ }
36
+ // Backtrack through the saved frontiers.
37
+ const script = [];
38
+ let x = n;
39
+ let y = m;
40
+ for (let d = trace.length - 1; d >= 0; d--) {
41
+ const vd = trace[d];
42
+ const at = (kk) => vd[kk + d];
43
+ const k = x - y;
44
+ const prevK = (k === -d || (k !== d && at(k - 1) < at(k + 1))) ? k + 1 : k - 1;
45
+ const prevX = d === 0 ? 0 : at(prevK);
46
+ const prevY = prevX - prevK;
47
+ while (x > prevX && y > prevY) { script.push({ op: ' ', a: --x, b: --y }); }
48
+ if (d > 0) {
49
+ if (x === prevX) script.push({ op: '+', a: x, b: --y });
50
+ else script.push({ op: '-', a: --x, b: y });
51
+ }
52
+ }
53
+ return script.reverse();
54
+ }
55
+
56
+ function unifiedDiff(oldText, newText, label) {
57
+ if (oldText === newText) return '';
58
+ const a = oldText.split('\n');
59
+ const b = newText.split('\n');
60
+ // A trailing newline yields an empty last element, which is not a line.
61
+ if (a.length > 1 && b.length > 1 && a[a.length - 1] === '' && b[b.length - 1] === '') { a.pop(); b.pop(); }
62
+ // Trim the shared prefix/suffix first so Myers only sees the changed middle.
63
+ let p = 0;
64
+ while (p < a.length && p < b.length && a[p] === b[p]) p++;
65
+ let s = 0;
66
+ while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
67
+ const oldMid = a.slice(p, a.length - s);
68
+ const newMid = b.slice(p, b.length - s);
69
+ const middle = myersDiff(oldMid, newMid) ?? [
70
+ ...oldMid.map((_, i) => ({ op: '-', a: i, b: 0 })),
71
+ ...newMid.map((_, i) => ({ op: '+', a: oldMid.length, b: i })),
72
+ ];
73
+ const script = [];
74
+ for (let i = 0; i < p; i++) script.push({ op: ' ', a: i, b: i });
75
+ for (const e of middle) script.push({ op: e.op, a: e.a + p, b: e.b + p });
76
+ for (let i = 0; i < s; i++) script.push({ op: ' ', a: a.length - s + i, b: b.length - s + i });
77
+
78
+ const lines = [`--- a/${label}`, `+++ b/${label}`];
79
+ let i = 0;
80
+ while (i < script.length) {
81
+ while (i < script.length && script[i].op === ' ') i++;
82
+ if (i >= script.length) break;
83
+ const start = Math.max(0, i - CONTEXT);
84
+ // Extend the hunk until a run of more than 2*CONTEXT unchanged lines.
85
+ let end = i;
86
+ let j = i;
87
+ while (j < script.length) {
88
+ if (script[j].op !== ' ') { end = j; j++; continue; }
89
+ let run = 0;
90
+ while (j + run < script.length && script[j + run].op === ' ') run++;
91
+ if (j + run >= script.length || run > 2 * CONTEXT) break;
92
+ j += run;
93
+ }
94
+ const stop = Math.min(script.length, end + 1 + CONTEXT);
95
+ const hunk = script.slice(start, stop);
96
+ const oldStart = hunk.find(e => e.op !== '+')?.a ?? script[start].a;
97
+ const newStart = hunk.find(e => e.op !== '-')?.b ?? script[start].b;
98
+ const oldLen = hunk.filter(e => e.op !== '+').length;
99
+ const newLen = hunk.filter(e => e.op !== '-').length;
100
+ lines.push(`@@ -${oldStart + 1},${oldLen} +${newStart + 1},${newLen} @@`);
101
+ for (const e of hunk) lines.push(`${e.op}${e.op === '+' ? b[e.b] : a[e.a]}`);
102
+ i = stop;
103
+ }
104
+ return lines.join('\n');
105
+ }
106
+
107
+ export { unifiedDiff };
@@ -32,9 +32,22 @@ function readOwnBackgroundColor(el, computedStyle) {
32
32
  return bg;
33
33
  }
34
34
 
35
+ // Static mode marks the result `uncertain` when the colour is a guess: a
36
+ // translucent layer was stacked on (or skipped over) the surface we found, a
37
+ // body-level gradient was assumed white, or the walk never reached <body> and
38
+ // fell back to the default white (a fragment's real surface is unknown).
39
+ // Contrast measured against an uncertain surface is reported as unverified.
40
+ function markUncertain(bg, uncertain) {
41
+ if (!DETECTOR_IS_BROWSER && uncertain) bg.uncertain = true;
42
+ return bg;
43
+ }
44
+
35
45
  function resolveBackground(el, win, customPropMap) {
36
46
  let current = el;
47
+ let sawTranslucent = false;
48
+ let sawBody = false;
37
49
  while (current && current.nodeType === 1) {
50
+ if (current.tagName === 'BODY' || current.tagName === 'HTML') sawBody = true;
38
51
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
39
52
  const bgImage = style.backgroundImage || '';
40
53
  const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage));
@@ -66,8 +79,9 @@ function resolveBackground(el, win, customPropMap) {
66
79
  }
67
80
 
68
81
  if (bg && bg.a > 0.1) {
69
- if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
82
+ if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return markUncertain(bg, sawTranslucent || bg.a < 1);
70
83
  }
84
+ if (bg && bg.a > 0) sawTranslucent = true;
71
85
  // No solid bg-color at this level. If THIS level has a gradient/url
72
86
  // with no underlying solid color we can read:
73
87
  // • on body/html: assume white. Body-level gradients are almost
@@ -82,13 +96,13 @@ function resolveBackground(el, win, customPropMap) {
82
96
  // bgs worth checking against).
83
97
  if (hasGradientOrUrl) {
84
98
  if (current.tagName === 'BODY' || current.tagName === 'HTML') {
85
- return { r: 255, g: 255, b: 255, a: 1 };
99
+ return markUncertain({ r: 255, g: 255, b: 255, a: 1 }, true);
86
100
  }
87
101
  return null;
88
102
  }
89
103
  current = current.parentElement;
90
104
  }
91
- return { r: 255, g: 255, b: 255 };
105
+ return markUncertain({ r: 255, g: 255, b: 255 }, sawTranslucent || !sawBody);
92
106
  }
93
107
 
94
108
  // Walk parents looking for a gradient background and return its color stops.
@@ -164,6 +164,8 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
164
164
  bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
165
165
  bgImage: style.backgroundImage || '',
166
166
  classList: el.getAttribute?.('class') || el.className || '',
167
+ // SVG text paints with `fill`, not `color`, so its CSS colour says nothing.
168
+ isSvgText: tag === 'text' || tag === 'tspan' || tag === 'textpath',
167
169
  });
168
170
  }
169
171
 
@@ -60,7 +60,7 @@ function isEmojiOnlyText(text) {
60
60
  }
61
61
 
62
62
  function checkColors(opts) {
63
- const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
63
+ const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList, isSvgText } = opts;
64
64
  if (SAFE_TAGS.has(tag)) {
65
65
  // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
66
66
  // exists to suppress contrast noise on inline links and unstyled controls,
@@ -108,7 +108,14 @@ function checkColors(opts) {
108
108
  // like `text-paper/60` on `bg-ink` sections are the FP pattern.
109
109
  const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
110
110
  if (!isAlphaFallbackFP) {
111
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
111
+ const snippet = `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}`;
112
+ // Static analysis can't composite translucent text/surfaces, see
113
+ // through gradients, or read SVG `fill`, so those ratios are guesses:
114
+ // report them as unverified rather than definite failures (#424).
115
+ const unverified = !DETECTOR_IS_BROWSER && (
116
+ (textColor.a ?? 1) < 1 || !effectiveBg || (effectiveBg.a ?? 1) < 1 || effectiveBg.uncertain || isSvgText
117
+ );
118
+ findings.push(unverified ? { id: 'low-contrast', snippet, unverified: true } : { id: 'low-contrast', snippet });
112
119
  }
113
120
  }
114
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoes/monodesign",
3
- "version": "1.2.22",
3
+ "version": "1.2.23",
4
4
  "description": "Frontend design intelligence for AI agents — design tokens, antipattern detection, and the monodesign skill",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "optionalDependencies": {
47
47
  "puppeteer": "^25.1.0",
48
- "@monoes/monobrowse": "1.0.27"
48
+ "@monoes/monobrowse": "1.0.28"
49
49
  },
50
50
  "scripts": {
51
51
  "prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",