@monoes/monodesign 1.2.22 → 1.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/engine/cli/main.mjs +21 -1
- package/cli/engine/detect-antipatterns-browser.js +28 -5
- package/cli/engine/detect-antipatterns.mjs +1 -0
- package/cli/engine/engines/static-html/detect-html.mjs +6 -1
- package/cli/engine/findings-group.mjs +94 -0
- package/cli/engine/fix/index.mjs +1 -26
- package/cli/engine/fix/unified-diff.mjs +107 -0
- package/cli/engine/rules/checks-background.mjs +17 -3
- package/cli/engine/rules/checks-element.mjs +2 -0
- package/cli/engine/rules/checks-pure.mjs +9 -2
- package/package.json +5 -3
- package/skill/SKILL.src.md +1 -1
package/README.md
CHANGED
|
@@ -101,7 +101,7 @@ Source: [`cli/engine/registry/antipatterns.mjs`](packages/@monoes/monodesign/cli
|
|
|
101
101
|
|---|---|---|
|
|
102
102
|
| `regex` | Source text / CSS-in-JS scan | none |
|
|
103
103
|
| `static-html` | HTMLparser2 static DOM parse | none |
|
|
104
|
-
| `browser` |
|
|
104
|
+
| `browser` | Live-page scan | `@monoes/monobrowse` (optional) with an installed Chrome, Chromium or Edge, else a `puppeteer` you install yourself |
|
|
105
105
|
| `visual` | Visual contrast analysis | `@monoes/monobrowse` (optional) |
|
|
106
106
|
|
|
107
107
|
### Auto-Fix
|
|
@@ -198,7 +198,7 @@ Search order: `DESIGN.md` → `.agents/context/DESIGN.md` → `docs/DESIGN.md`.
|
|
|
198
198
|
```
|
|
199
199
|
|
|
200
200
|
**Dependencies:** `css-select`, `css-tree`, `domutils`, `fflate`, `htmlparser2`, `marked`
|
|
201
|
-
**Optional:** `@monoes/monobrowse` (browser detection)
|
|
201
|
+
**Optional:** `@monoes/monobrowse` (browser detection and live URL scanning). `puppeteer` is no longer a dependency; install it yourself only for URL scans on a machine with no Chrome, Chromium or Edge. Through `monomind design detect`, monomind downloads a Chrome on first use instead.
|
|
202
202
|
|
|
203
203
|
---
|
|
204
204
|
|
package/cli/engine/cli/main.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/cli/engine/fix/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.2.24",
|
|
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",
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"marked": "^18.0.0"
|
|
45
45
|
},
|
|
46
46
|
"optionalDependencies": {
|
|
47
|
-
"
|
|
48
|
-
|
|
47
|
+
"@monoes/monobrowse": "1.0.30"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"puppeteer": "^25.1.0"
|
|
49
51
|
},
|
|
50
52
|
"scripts": {
|
|
51
53
|
"prebuild": "node ../../../scripts/build-fs.mjs clean dist tsconfig.tsbuildinfo",
|
package/skill/SKILL.src.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: monodesign
|
|
3
|
-
description: "
|
|
3
|
+
description: "Design, redesign, critique, audit or polish a frontend UI: landing pages, dashboards, components, forms; make it bolder or quieter; type, color, motion, a11y, design systems. Not for non-UI."
|
|
4
4
|
version: 2.0.0
|
|
5
5
|
argument-hint: "[{{command_hint}}] [target]"
|
|
6
6
|
user-invocable: true
|