@monoes/monodesign 1.1.1 → 1.2.0
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/cli/bin/cli.js +5 -0
- package/cli/engine/browser/injected/index.mjs +25 -0
- package/cli/engine/detect-antipatterns-browser.js +356 -0
- package/cli/engine/engines/regex/detect-text.mjs +63 -0
- package/cli/engine/engines/static-html/detect-html.mjs +19 -0
- package/cli/engine/fix/css-regions.mjs +181 -0
- package/cli/engine/fix/fixers.mjs +373 -0
- package/cli/engine/fix/index.mjs +426 -0
- package/cli/engine/registry/antipatterns.mjs +49 -0
- package/cli/engine/rules/checks.mjs +292 -0
- package/package.json +2 -2
- package/skill/SKILL.src.md +1 -1
- package/skill/reference/antipatterns-catalog.md +47 -2
- package/skill/reference/critique.md +1 -1
- package/skill/reference/live.md +0 -8
- package/skill/reference/polish.md +2 -2
- package/skill/scripts/context-signals.mjs +39 -5
- package/skill/scripts/critique-storage.mjs +175 -1
- package/skill/scripts/live/session-store.mjs +5 -1
- package/src/rules.ts +60 -3
package/cli/bin/cli.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* monomind design detect [file-or-dir-or-url...]
|
|
8
|
+
* monomind design fix <file-or-dir...> [--dry-run] [--json] [--rule <id,...>]
|
|
8
9
|
* monomind design ignores <list|add-file|add-value|remove-...>
|
|
9
10
|
* monomind design --help
|
|
10
11
|
*/
|
|
@@ -35,6 +36,7 @@ async function main() {
|
|
|
35
36
|
|
|
36
37
|
Commands:
|
|
37
38
|
detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues
|
|
39
|
+
fix <file-or-dir...> Auto-fix findings with safe codemods (--dry-run, --json, --rule)
|
|
38
40
|
ignores Manage detector ignore rules, files, and values
|
|
39
41
|
|
|
40
42
|
Options:
|
|
@@ -55,6 +57,9 @@ The monodesign skill itself ships with monomind (npx monomind init).`);
|
|
|
55
57
|
process.argv = [process.argv[0], process.argv[1], ...args.slice(1)];
|
|
56
58
|
const { detectCli } = await import('../engine/detect-antipatterns.mjs');
|
|
57
59
|
await detectCli();
|
|
60
|
+
} else if (command === 'fix') {
|
|
61
|
+
const { runFixCli } = await import('../engine/fix/index.mjs');
|
|
62
|
+
await runFixCli(args.slice(1));
|
|
58
63
|
} else if (command === 'ignores' || command === 'ignore') {
|
|
59
64
|
const { run } = await import('./commands/ignores.mjs');
|
|
60
65
|
await run(args.slice(1));
|
|
@@ -1488,6 +1488,8 @@ if (IS_BROWSER) {
|
|
|
1488
1488
|
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1489
1489
|
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1490
1490
|
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1491
|
+
...checkElementImageDimensionsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1492
|
+
...checkElementSmallTouchTargetDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
1491
1493
|
...checkElementDesignSystemDOM(el, designSystem, designSeen),
|
|
1492
1494
|
].filter(f => _ruleOk(f.type));
|
|
1493
1495
|
|
|
@@ -1547,6 +1549,29 @@ if (IS_BROWSER) {
|
|
|
1547
1549
|
addBrowserFindings(groupMap, document.body, creamFindings);
|
|
1548
1550
|
}
|
|
1549
1551
|
|
|
1552
|
+
// Stylesheet-level accessibility rules (focus-visible, hover-only,
|
|
1553
|
+
// dark-scheme). Serialize every same-origin stylesheet to CSS text and
|
|
1554
|
+
// run the shared analyzers — cross-origin sheets throw on cssRules access
|
|
1555
|
+
// and are skipped.
|
|
1556
|
+
let stylesheetCssText = '';
|
|
1557
|
+
for (const sheet of Array.from(document.styleSheets || [])) {
|
|
1558
|
+
let rules;
|
|
1559
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
1560
|
+
if (!rules) continue;
|
|
1561
|
+
for (const rule of Array.from(rules)) {
|
|
1562
|
+
try { stylesheetCssText += rule.cssText + '\n'; } catch { /* ignore */ }
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
const stylesheetFindings = [
|
|
1566
|
+
...checkFocusVisible(stylesheetCssText),
|
|
1567
|
+
...checkHoverOnlyAffordance(stylesheetCssText),
|
|
1568
|
+
...checkDarkSchemeContrast(stylesheetCssText),
|
|
1569
|
+
].map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
|
|
1570
|
+
if (stylesheetFindings.length > 0) {
|
|
1571
|
+
pageLevelFindings.push(...stylesheetFindings);
|
|
1572
|
+
addBrowserFindings(groupMap, document.body, stylesheetFindings);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1550
1575
|
// Regex-on-HTML checks (shared with Node)
|
|
1551
1576
|
// Clone the document and strip monodesign-live overlay nodes before the
|
|
1552
1577
|
// regex scan, so the inspector's own inline styles (transitions on top/
|
|
@@ -490,6 +490,55 @@ const ANTIPATTERNS = [
|
|
|
490
490
|
skillGuideline: 'font size outside the project design system',
|
|
491
491
|
},
|
|
492
492
|
|
|
493
|
+
{
|
|
494
|
+
id: 'missing-focus-visible',
|
|
495
|
+
category: 'quality',
|
|
496
|
+
name: 'Suppressed focus outline with no replacement',
|
|
497
|
+
description:
|
|
498
|
+
'An interactive element (link, button, input) removes its focus outline (outline: none / 0) but the stylesheet never provides a :focus-visible or :focus replacement. Keyboard users lose all sense of where they are. Remove the suppression, or pair it with a visible :focus-visible ring (outline, box-shadow, or border).',
|
|
499
|
+
skillSection: 'Interaction',
|
|
500
|
+
skillGuideline: 'outline removed without a focus-visible replacement',
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
id: 'small-touch-target',
|
|
504
|
+
category: 'quality',
|
|
505
|
+
scopes: ['layout'],
|
|
506
|
+
name: 'Touch target below 44px',
|
|
507
|
+
description:
|
|
508
|
+
'A clickable control (button, link, input, [role=button]) renders smaller than 44×44px on one or both axes. Fingers miss small targets and mis-tap neighbors. Give standalone controls at least 44×44px of hit area via padding or min-width/height. Inline text links inside prose are exempt.',
|
|
509
|
+
skillSection: 'Layout & Space',
|
|
510
|
+
skillGuideline: 'touch target smaller than 44px',
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
id: 'hover-only-affordance',
|
|
514
|
+
category: 'quality',
|
|
515
|
+
name: 'Functionality gated behind hover only',
|
|
516
|
+
description:
|
|
517
|
+
'An element is hidden by default and revealed only on :hover, with no :focus, :focus-within, or :active equivalent. Keyboard and touch users can never reach it. Mirror every hover reveal with a focus-within (or active) rule so the affordance is reachable without a pointer.',
|
|
518
|
+
skillSection: 'Interaction',
|
|
519
|
+
skillGuideline: 'affordance revealed on hover with no focus equivalent',
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
id: 'image-missing-dimensions',
|
|
523
|
+
category: 'quality',
|
|
524
|
+
scopes: ['layout'],
|
|
525
|
+
name: 'Image without reserved dimensions',
|
|
526
|
+
description:
|
|
527
|
+
'An <img> ships without width and height attributes and without a CSS aspect-ratio or explicit height. The browser cannot reserve space before the image loads, so surrounding content jumps (cumulative layout shift). Set width and height attributes, or give the image a CSS aspect-ratio.',
|
|
528
|
+
skillSection: 'Imagery',
|
|
529
|
+
skillGuideline: 'image with no reserved dimensions',
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
id: 'dark-scheme-contrast-blindspot',
|
|
533
|
+
category: 'quality',
|
|
534
|
+
severity: 'advisory',
|
|
535
|
+
name: 'Dark-scheme contrast blindspot',
|
|
536
|
+
description:
|
|
537
|
+
'The page ships dark styling (a prefers-color-scheme: dark block or a .dark / [data-theme=dark] scope), but for some selector the dark override changes the background without changing the paired text color (or the reverse). The half-updated pair often collapses to unreadable contrast in dark mode. Override text and background together, or verify the inherited half still contrasts.',
|
|
538
|
+
skillSection: 'Color & Contrast',
|
|
539
|
+
skillGuideline: 'dark scheme overrides one of a color pair but not the other',
|
|
540
|
+
},
|
|
541
|
+
|
|
493
542
|
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
|
494
543
|
{
|
|
495
544
|
id: 'gpt-thin-border-wide-shadow',
|
|
@@ -3303,6 +3352,288 @@ function checkElementTextOverflowDOM(el) {
|
|
|
3303
3352
|
return [];
|
|
3304
3353
|
}
|
|
3305
3354
|
|
|
3355
|
+
// ─── Section 7: Stylesheet-level accessibility rules ─────────────────────────
|
|
3356
|
+
// These operate on raw CSS text (concatenated <style> blocks + linked sheets),
|
|
3357
|
+
// not per-element computed style — because :hover / :focus-visible / @media
|
|
3358
|
+
// conditions can't be read off a resting element. Shared by the regex, static
|
|
3359
|
+
// and browser engines (each hands over its own CSS text).
|
|
3360
|
+
|
|
3361
|
+
// Parse flat CSS into { selector, body, dark } tuples, tracking one level of
|
|
3362
|
+
// dark-scheme context (a `@media (prefers-color-scheme: dark)` ancestor).
|
|
3363
|
+
// @supports / @layer / @container / @scope are transparently descended.
|
|
3364
|
+
// @font-face / @keyframes and other at-rules are skipped.
|
|
3365
|
+
function collectCssRules(css, dark = false, depth = 0) {
|
|
3366
|
+
const out = [];
|
|
3367
|
+
if (!css || depth > 6) return out;
|
|
3368
|
+
const src = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
3369
|
+
let i = 0;
|
|
3370
|
+
while (i < src.length) {
|
|
3371
|
+
const braceStart = src.indexOf('{', i);
|
|
3372
|
+
if (braceStart === -1) break;
|
|
3373
|
+
const prelude = src.slice(i, braceStart).trim();
|
|
3374
|
+
let d = 1, j = braceStart + 1;
|
|
3375
|
+
for (; j < src.length; j++) {
|
|
3376
|
+
if (src[j] === '{') d++;
|
|
3377
|
+
else if (src[j] === '}') { d--; if (d === 0) break; }
|
|
3378
|
+
}
|
|
3379
|
+
const body = src.slice(braceStart + 1, j);
|
|
3380
|
+
i = j + 1;
|
|
3381
|
+
if (!prelude) continue;
|
|
3382
|
+
if (prelude.startsWith('@')) {
|
|
3383
|
+
const atName = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
|
3384
|
+
if (['media', 'supports', 'layer', 'container', 'scope'].includes(atName)) {
|
|
3385
|
+
const isDarkMedia = atName === 'media' && /prefers-color-scheme\s*:\s*dark/i.test(prelude);
|
|
3386
|
+
out.push(...collectCssRules(body, dark || isDarkMedia, depth + 1));
|
|
3387
|
+
}
|
|
3388
|
+
continue;
|
|
3389
|
+
}
|
|
3390
|
+
for (const sel of prelude.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
3391
|
+
out.push({ selector: sel, body, dark });
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
return out;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
// --- missing-focus-visible ---
|
|
3398
|
+
function selectorTargetsInteractive(sel) {
|
|
3399
|
+
return /(^|[\s>+~(,])(a|button|input|select|textarea|summary)(\b|[.:#[])/i.test(sel)
|
|
3400
|
+
|| /:focus\b/i.test(sel)
|
|
3401
|
+
|| /\[role\s*=\s*["']?button["']?\]/i.test(sel);
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function checkFocusVisible(cssText) {
|
|
3405
|
+
if (!cssText) return [];
|
|
3406
|
+
const rules = collectCssRules(cssText);
|
|
3407
|
+
let suppressor = null;
|
|
3408
|
+
let hasFocusVisible = false;
|
|
3409
|
+
let hasFocusReplacement = false;
|
|
3410
|
+
for (const r of rules) {
|
|
3411
|
+
const sel = r.selector.toLowerCase();
|
|
3412
|
+
const body = r.body.toLowerCase();
|
|
3413
|
+
if (/:focus-visible/.test(sel)) {
|
|
3414
|
+
// Any :focus-visible styling counts as deliberate focus handling.
|
|
3415
|
+
hasFocusVisible = true;
|
|
3416
|
+
}
|
|
3417
|
+
const suppresses = /outline\s*:\s*(none|0(?:px)?)\b/.test(body)
|
|
3418
|
+
|| /outline-style\s*:\s*none/.test(body)
|
|
3419
|
+
|| /outline-width\s*:\s*0(?:px)?\b/.test(body);
|
|
3420
|
+
if (suppresses && selectorTargetsInteractive(sel) && !/:focus-visible/.test(sel)) {
|
|
3421
|
+
suppressor = suppressor || r;
|
|
3422
|
+
}
|
|
3423
|
+
// A plain :focus rule that draws a visible affordance is a valid fallback.
|
|
3424
|
+
if (/:focus\b/.test(sel) && !/:focus-visible/.test(sel)) {
|
|
3425
|
+
const olVal = /outline\s*:\s*([^;}]+)/.exec(body);
|
|
3426
|
+
const outlineReplaces = olVal && !/^\s*(none|0(?:px)?|inherit|initial|unset)\s*$/.test(olVal[1]);
|
|
3427
|
+
if (/box-shadow\s*:/.test(body)
|
|
3428
|
+
|| outlineReplaces
|
|
3429
|
+
|| /outline-(?:color|width|style)\s*:\s*(?!none|0(?:px)?\b)/.test(body)
|
|
3430
|
+
|| /border(?:-\w+)?(?:-color)?\s*:/.test(body)) {
|
|
3431
|
+
hasFocusReplacement = true;
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
if (suppressor && !hasFocusVisible && !hasFocusReplacement) {
|
|
3436
|
+
return [{ id: 'missing-focus-visible', snippet: `"${suppressor.selector}" removes outline with no :focus-visible replacement` }];
|
|
3437
|
+
}
|
|
3438
|
+
return [];
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// --- hover-only-affordance ---
|
|
3442
|
+
function lastCompound(sel) {
|
|
3443
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
3444
|
+
const last = parts[parts.length - 1] || sel;
|
|
3445
|
+
return last.replace(/:{1,2}[a-z-]+(\([^)]*\))?/gi, '').trim().toLowerCase();
|
|
3446
|
+
}
|
|
3447
|
+
function bodyRevealsVisible(body) {
|
|
3448
|
+
const b = body.toLowerCase();
|
|
3449
|
+
if (/display\s*:\s*(block|flex|grid|inline(?:-\w+)?|table|list-item)/.test(b)) return true;
|
|
3450
|
+
if (/visibility\s*:\s*visible/.test(b)) return true;
|
|
3451
|
+
if (/opacity\s*:\s*(?:1(?:\.0+)?|0?\.[1-9]\d*|\.\d*[1-9])\b/.test(b)) return true;
|
|
3452
|
+
return false;
|
|
3453
|
+
}
|
|
3454
|
+
function bodyHidesByDefault(body) {
|
|
3455
|
+
const b = body.toLowerCase();
|
|
3456
|
+
return /display\s*:\s*none/.test(b)
|
|
3457
|
+
|| /visibility\s*:\s*hidden/.test(b)
|
|
3458
|
+
|| /opacity\s*:\s*0(?:\.0+)?\b/.test(b);
|
|
3459
|
+
}
|
|
3460
|
+
function checkHoverOnlyAffordance(cssText) {
|
|
3461
|
+
if (!cssText) return [];
|
|
3462
|
+
const rules = collectCssRules(cssText);
|
|
3463
|
+
const focusTargets = new Set();
|
|
3464
|
+
const hiddenTargets = new Set();
|
|
3465
|
+
for (const r of rules) {
|
|
3466
|
+
const sel = r.selector;
|
|
3467
|
+
if (/:(focus|focus-within|focus-visible|active)\b/i.test(sel) && bodyRevealsVisible(r.body)) {
|
|
3468
|
+
focusTargets.add(lastCompound(sel));
|
|
3469
|
+
}
|
|
3470
|
+
if (!/:hover\b/i.test(sel) && bodyHidesByDefault(r.body)) {
|
|
3471
|
+
hiddenTargets.add(lastCompound(sel));
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
const findings = [];
|
|
3475
|
+
const seen = new Set();
|
|
3476
|
+
for (const r of rules) {
|
|
3477
|
+
const sel = r.selector;
|
|
3478
|
+
if (!/:hover\b/i.test(sel)) continue;
|
|
3479
|
+
if (!bodyRevealsVisible(r.body)) continue;
|
|
3480
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
3481
|
+
if (parts.length < 2) continue; // hover on the subject itself — can't hide+reveal
|
|
3482
|
+
if (/:hover\b/i.test(parts[parts.length - 1])) continue; // :hover is on the revealed subject
|
|
3483
|
+
const target = lastCompound(sel);
|
|
3484
|
+
if (!target || !hiddenTargets.has(target)) continue;
|
|
3485
|
+
if (focusTargets.has(target)) continue;
|
|
3486
|
+
if (seen.has(target)) continue;
|
|
3487
|
+
seen.add(target);
|
|
3488
|
+
findings.push({ id: 'hover-only-affordance', snippet: `"${target}" is revealed on :hover with no focus/active equivalent` });
|
|
3489
|
+
}
|
|
3490
|
+
return findings;
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3493
|
+
// --- dark-scheme-contrast-blindspot ---
|
|
3494
|
+
const DARK_SCOPE_RE = /(?:^|[\s>+~(,])(?:html|:root|body)?(?:\.dark|\.theme-dark|\.dark-mode)\b|\[data-theme\s*[~|]?=\s*["']?dark["']?\]|\[data-mode\s*=\s*["']?dark["']?\]|\[data-color-scheme\s*=\s*["']?dark["']?\]/i;
|
|
3495
|
+
function stripDarkScope(sel) {
|
|
3496
|
+
return sel
|
|
3497
|
+
.replace(/(?:html|:root|body)?(?:\.dark|\.theme-dark|\.dark-mode)\b/gi, '')
|
|
3498
|
+
.replace(/\[data-(?:theme|mode|color-scheme)\s*[~|]?=\s*["']?dark["']?\]/gi, '')
|
|
3499
|
+
.replace(/\s*[>+~]\s*/g, ' ')
|
|
3500
|
+
.replace(/\s+/g, ' ')
|
|
3501
|
+
.trim();
|
|
3502
|
+
}
|
|
3503
|
+
function normalizeSelKey(sel) {
|
|
3504
|
+
return sel.replace(/\s*[>+~]\s*/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
3505
|
+
}
|
|
3506
|
+
function declHasColor(body) { return /(?:^|[;{\s])color\s*:/i.test(body); }
|
|
3507
|
+
function declHasBg(body) { return /(?:^|[;{\s])background(?:-color)?\s*:/i.test(body); }
|
|
3508
|
+
function checkDarkSchemeContrast(cssText) {
|
|
3509
|
+
if (!cssText) return [];
|
|
3510
|
+
const rules = collectCssRules(cssText);
|
|
3511
|
+
const hasDarkMedia = /@media[^{]*prefers-color-scheme\s*:\s*dark/i.test(cssText);
|
|
3512
|
+
const hasDarkSelectors = rules.some(r => DARK_SCOPE_RE.test(r.selector));
|
|
3513
|
+
if (!hasDarkMedia && !hasDarkSelectors) return [];
|
|
3514
|
+
|
|
3515
|
+
const light = new Map(); // base selector → { color, bg }
|
|
3516
|
+
const darkAgg = new Map();
|
|
3517
|
+
for (const r of rules) {
|
|
3518
|
+
const scopedDark = DARK_SCOPE_RE.test(r.selector);
|
|
3519
|
+
const isDark = r.dark || scopedDark;
|
|
3520
|
+
if (isDark) {
|
|
3521
|
+
const base = normalizeSelKey(scopedDark ? stripDarkScope(r.selector) : r.selector);
|
|
3522
|
+
if (!base) continue;
|
|
3523
|
+
const e = darkAgg.get(base) || { color: false, bg: false };
|
|
3524
|
+
if (declHasColor(r.body)) e.color = true;
|
|
3525
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
3526
|
+
darkAgg.set(base, e);
|
|
3527
|
+
} else {
|
|
3528
|
+
const base = normalizeSelKey(r.selector);
|
|
3529
|
+
if (!base) continue;
|
|
3530
|
+
const e = light.get(base) || { color: false, bg: false };
|
|
3531
|
+
if (declHasColor(r.body)) e.color = true;
|
|
3532
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
3533
|
+
light.set(base, e);
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
const findings = [];
|
|
3538
|
+
for (const [base, d] of darkAgg) {
|
|
3539
|
+
if (d.color === d.bg) continue; // sets both or neither — not a split pair
|
|
3540
|
+
const lp = light.get(base);
|
|
3541
|
+
if (!lp || !(lp.color && lp.bg)) continue; // light scheme must pair both
|
|
3542
|
+
const changed = d.bg ? 'background' : 'text color';
|
|
3543
|
+
const missing = d.bg ? 'text color' : 'background';
|
|
3544
|
+
findings.push({ id: 'dark-scheme-contrast-blindspot', snippet: `"${base}" overrides ${changed} in dark scheme but not ${missing}` });
|
|
3545
|
+
}
|
|
3546
|
+
return findings;
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
// --- image-missing-dimensions ---
|
|
3550
|
+
function checkImageDimensions({ hasWidthAttr, hasHeightAttr, style }) {
|
|
3551
|
+
const s = String(style || '').toLowerCase();
|
|
3552
|
+
if (hasWidthAttr && hasHeightAttr) return [];
|
|
3553
|
+
if (/aspect-?ratio\s*:/.test(s)) return [];
|
|
3554
|
+
const heightM = /(?:^|[;{\s])height\s*:\s*([^;]+)/.exec(s);
|
|
3555
|
+
if (heightM && !/^\s*(auto|inherit|initial|unset|0(?:px)?)\s*$/.test(heightM[1])) return [];
|
|
3556
|
+
return [{ id: 'image-missing-dimensions', snippet: '<img> without width/height attributes or CSS aspect-ratio (layout shift risk)' }];
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
function checkElementImageDimensions(el, style) {
|
|
3560
|
+
const hasWidthAttr = (el.getAttribute?.('width') ?? el.attribs?.width) != null;
|
|
3561
|
+
const hasHeightAttr = (el.getAttribute?.('height') ?? el.attribs?.height) != null;
|
|
3562
|
+
const parts = [el.getAttribute?.('style') || el.attribs?.style || ''];
|
|
3563
|
+
const ar = style && (style.aspectRatio || style['aspect-ratio']);
|
|
3564
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
3565
|
+
const h = style && style.height;
|
|
3566
|
+
if (h && h !== 'auto' && h !== '') parts.push(`height:${h}`);
|
|
3567
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
function checkElementImageDimensionsDOM(el) {
|
|
3571
|
+
const hasWidthAttr = el.getAttribute('width') != null;
|
|
3572
|
+
const hasHeightAttr = el.getAttribute('height') != null;
|
|
3573
|
+
// Browser computed `height` always resolves to a used px value after layout,
|
|
3574
|
+
// so it can't tell a reserved height from a rendered one — only use inline
|
|
3575
|
+
// style + the computed aspect-ratio (which stays 'auto' unless authored).
|
|
3576
|
+
const parts = [el.getAttribute('style') || ''];
|
|
3577
|
+
const style = getComputedStyle(el);
|
|
3578
|
+
const ar = style.aspectRatio || '';
|
|
3579
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
3580
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// --- small-touch-target ---
|
|
3584
|
+
const CLICKABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'image']);
|
|
3585
|
+
const CLICKABLE_ROLES = new Set(['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'checkbox', 'radio', 'option']);
|
|
3586
|
+
function isClickableControl(tag, role, href, inputType) {
|
|
3587
|
+
if (tag === 'button' || tag === 'select' || tag === 'summary') return true;
|
|
3588
|
+
if (tag === 'a') return href != null && href !== '';
|
|
3589
|
+
if (tag === 'input') return CLICKABLE_INPUT_TYPES.has(String(inputType || '').toLowerCase());
|
|
3590
|
+
if (role && CLICKABLE_ROLES.has(role)) return true;
|
|
3591
|
+
return false;
|
|
3592
|
+
}
|
|
3593
|
+
function checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse }) {
|
|
3594
|
+
if (!isClickableControl(tag, role, href, inputType)) return [];
|
|
3595
|
+
if (isInlineLinkInProse) return [];
|
|
3596
|
+
if (!(widthPx > 0) || !(heightPx > 0)) return [];
|
|
3597
|
+
if (widthPx >= 44 && heightPx >= 44) return [];
|
|
3598
|
+
const roleLabel = role && !['button', 'a', 'input', 'select'].includes(tag) ? `[role=${role}]` : '';
|
|
3599
|
+
return [{ id: 'small-touch-target', snippet: `<${tag}>${roleLabel} renders ${Math.round(widthPx)}x${Math.round(heightPx)}px (min 44x44)` }];
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
const PROSE_ANCESTOR_SELECTOR = 'p,li,blockquote,figcaption,dd,dt';
|
|
3603
|
+
function checkElementSmallTouchTarget(el, style, tag) {
|
|
3604
|
+
// Static path: only provable when width AND height are explicit px lengths
|
|
3605
|
+
// (jsdom performs no layout; rem/%/auto can't be resolved to a real box).
|
|
3606
|
+
const wRaw = style.width || '';
|
|
3607
|
+
const hRaw = style.height || '';
|
|
3608
|
+
if (!/px\s*$/.test(wRaw) || !/px\s*$/.test(hRaw)) return [];
|
|
3609
|
+
const widthPx = parseFloat(wRaw);
|
|
3610
|
+
const heightPx = parseFloat(hRaw);
|
|
3611
|
+
const href = el.getAttribute?.('href');
|
|
3612
|
+
const role = (el.getAttribute?.('role') || '').toLowerCase();
|
|
3613
|
+
const inputType = el.getAttribute?.('type') || '';
|
|
3614
|
+
// An <a> is a prose text link unless it is explicitly laid out as a block
|
|
3615
|
+
// (button-like). jsdom returns '' for the default inline display, so key
|
|
3616
|
+
// off "not blockish" rather than "== inline".
|
|
3617
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
3618
|
+
const inProse = !!(el.closest && el.closest(PROSE_ANCESTOR_SELECTOR));
|
|
3619
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
3620
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse });
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
function checkElementSmallTouchTargetDOM(el) {
|
|
3624
|
+
const tag = el.tagName.toLowerCase();
|
|
3625
|
+
const rect = el.getBoundingClientRect();
|
|
3626
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return [];
|
|
3627
|
+
const href = el.getAttribute('href');
|
|
3628
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
3629
|
+
const inputType = el.getAttribute('type') || '';
|
|
3630
|
+
const style = getComputedStyle(el);
|
|
3631
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
3632
|
+
const inProse = !!(el.closest && el.closest(PROSE_ANCESTOR_SELECTOR));
|
|
3633
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
3634
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx: rect.width, heightPx: rect.height, isInlineLinkInProse });
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3306
3637
|
// --- cli/engine/browser/injected/index.mjs ---
|
|
3307
3638
|
const IS_BROWSER = typeof window !== 'undefined';
|
|
3308
3639
|
|
|
@@ -4794,6 +5125,8 @@ if (IS_BROWSER) {
|
|
|
4794
5125
|
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4795
5126
|
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4796
5127
|
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
5128
|
+
...checkElementImageDimensionsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
5129
|
+
...checkElementSmallTouchTargetDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
|
4797
5130
|
...checkElementDesignSystemDOM(el, designSystem, designSeen),
|
|
4798
5131
|
].filter(f => _ruleOk(f.type));
|
|
4799
5132
|
|
|
@@ -4853,6 +5186,29 @@ if (IS_BROWSER) {
|
|
|
4853
5186
|
addBrowserFindings(groupMap, document.body, creamFindings);
|
|
4854
5187
|
}
|
|
4855
5188
|
|
|
5189
|
+
// Stylesheet-level accessibility rules (focus-visible, hover-only,
|
|
5190
|
+
// dark-scheme). Serialize every same-origin stylesheet to CSS text and
|
|
5191
|
+
// run the shared analyzers — cross-origin sheets throw on cssRules access
|
|
5192
|
+
// and are skipped.
|
|
5193
|
+
let stylesheetCssText = '';
|
|
5194
|
+
for (const sheet of Array.from(document.styleSheets || [])) {
|
|
5195
|
+
let rules;
|
|
5196
|
+
try { rules = sheet.cssRules; } catch { continue; }
|
|
5197
|
+
if (!rules) continue;
|
|
5198
|
+
for (const rule of Array.from(rules)) {
|
|
5199
|
+
try { stylesheetCssText += rule.cssText + '\n'; } catch { /* ignore */ }
|
|
5200
|
+
}
|
|
5201
|
+
}
|
|
5202
|
+
const stylesheetFindings = [
|
|
5203
|
+
...checkFocusVisible(stylesheetCssText),
|
|
5204
|
+
...checkHoverOnlyAffordance(stylesheetCssText),
|
|
5205
|
+
...checkDarkSchemeContrast(stylesheetCssText),
|
|
5206
|
+
].map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
|
|
5207
|
+
if (stylesheetFindings.length > 0) {
|
|
5208
|
+
pageLevelFindings.push(...stylesheetFindings);
|
|
5209
|
+
addBrowserFindings(groupMap, document.body, stylesheetFindings);
|
|
5210
|
+
}
|
|
5211
|
+
|
|
4856
5212
|
// Regex-on-HTML checks (shared with Node)
|
|
4857
5213
|
// Clone the document and strip monodesign-live overlay nodes before the
|
|
4858
5214
|
// regex scan, so the inspector's own inline styles (transitions on top/
|
|
@@ -7,6 +7,11 @@ import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
|
|
7
7
|
import { finding } from '../../findings.mjs';
|
|
8
8
|
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
|
9
9
|
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
|
10
|
+
import {
|
|
11
|
+
checkFocusVisible,
|
|
12
|
+
checkHoverOnlyAffordance,
|
|
13
|
+
checkDarkSchemeContrast,
|
|
14
|
+
} from '../../rules/checks.mjs';
|
|
10
15
|
|
|
11
16
|
// ---------------------------------------------------------------------------
|
|
12
17
|
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
|
|
@@ -165,6 +170,17 @@ const REGEX_MATCHERS = [
|
|
|
165
170
|
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
|
|
166
171
|
test: (m) => !/\bsrc\s*=/i.test(m[0]),
|
|
167
172
|
fmt: (m) => m[0].slice(0, 100) },
|
|
173
|
+
// --- Image missing dimensions (CLS): <img> without width+height and
|
|
174
|
+
// without a CSS aspect-ratio or explicit height. Covers HTML (width="…")
|
|
175
|
+
// and JSX (width={…}) attribute forms. ---
|
|
176
|
+
{ id: 'image-missing-dimensions', regex: /<img\b[^>]*>/gi,
|
|
177
|
+
test: (m) => {
|
|
178
|
+
const tag = m[0];
|
|
179
|
+
const hasHeight = /(?:^|[\s{;"'])height\s*[=:]/i.test(tag);
|
|
180
|
+
const hasAspect = /aspect-?ratio/i.test(tag);
|
|
181
|
+
return !hasHeight && !hasAspect;
|
|
182
|
+
},
|
|
183
|
+
fmt: (m) => m[0].slice(0, 100) },
|
|
168
184
|
];
|
|
169
185
|
|
|
170
186
|
const REGEX_ANALYZERS = [
|
|
@@ -464,6 +480,48 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
|
|
|
464
480
|
return findings;
|
|
465
481
|
}
|
|
466
482
|
|
|
483
|
+
// Extract raw CSS text from a source file for the stylesheet-level analyzers
|
|
484
|
+
// (focus-visible, hover-only, dark-scheme). CSS-like files are CSS wholesale;
|
|
485
|
+
// everything else contributes only its <style> block contents so tag markup
|
|
486
|
+
// and inline styles don't confuse the flat CSS parser.
|
|
487
|
+
const CSS_LIKE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
488
|
+
function extractStylesheetText(content, ext) {
|
|
489
|
+
if (CSS_LIKE_EXTS.has(ext)) return content;
|
|
490
|
+
const blocks = [];
|
|
491
|
+
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
492
|
+
let m;
|
|
493
|
+
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
|
|
494
|
+
return blocks.join('\n');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const STYLESHEET_ANALYZERS = [
|
|
498
|
+
{ id: 'missing-focus-visible', run: checkFocusVisible },
|
|
499
|
+
{ id: 'hover-only-affordance', run: checkHoverOnlyAffordance },
|
|
500
|
+
{ id: 'dark-scheme-contrast-blindspot', run: checkDarkSchemeContrast },
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
// Run the stylesheet-level analyzers over a source file's CSS. Applies to
|
|
504
|
+
// CSS-like sources and to full HTML pages (which carry <style> blocks).
|
|
505
|
+
function runStylesheetAnalyzers(content, filePath, options = {}) {
|
|
506
|
+
const profile = options?.profile;
|
|
507
|
+
const ext = extFromFilePath(filePath);
|
|
508
|
+
const isCssLike = CSS_LIKE_EXTS.has(ext);
|
|
509
|
+
if (!isCssLike && !isFullPage(content)) return [];
|
|
510
|
+
const cssText = extractStylesheetText(content, ext);
|
|
511
|
+
if (!cssText.trim()) return [];
|
|
512
|
+
const findings = [];
|
|
513
|
+
for (const analyzer of STYLESHEET_ANALYZERS) {
|
|
514
|
+
const hits = profileFindings(profile, {
|
|
515
|
+
engine: 'regex',
|
|
516
|
+
phase: 'stylesheet',
|
|
517
|
+
ruleId: analyzer.id,
|
|
518
|
+
target: filePath,
|
|
519
|
+
}, () => analyzer.run(cssText));
|
|
520
|
+
for (const h of hits) findings.push(finding(h.id, filePath, h.snippet));
|
|
521
|
+
}
|
|
522
|
+
return findings;
|
|
523
|
+
}
|
|
524
|
+
|
|
467
525
|
function detectText(content, filePath, options = {}) {
|
|
468
526
|
const profile = options?.profile;
|
|
469
527
|
const findings = [];
|
|
@@ -555,6 +613,10 @@ function detectText(content, filePath, options = {}) {
|
|
|
555
613
|
}
|
|
556
614
|
}
|
|
557
615
|
|
|
616
|
+
// Stylesheet-level accessibility analyzers (focus-visible, hover-only,
|
|
617
|
+
// dark-scheme). Run on CSS-like sources and full HTML pages.
|
|
618
|
+
deduped.push(...runStylesheetAnalyzers(content, filePath, options));
|
|
619
|
+
|
|
558
620
|
const byProvider = filterByProviders(deduped, options?.providers);
|
|
559
621
|
// Inline `monodesign-disable*` waivers travel with the file; honor them unless
|
|
560
622
|
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
|
@@ -569,5 +631,6 @@ export {
|
|
|
569
631
|
extractCSSinJS,
|
|
570
632
|
runRegexMatchers,
|
|
571
633
|
runTextContentAnalyzers,
|
|
634
|
+
runStylesheetAnalyzers,
|
|
572
635
|
detectText,
|
|
573
636
|
};
|
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
checkPageLayout,
|
|
29
29
|
checkPageQualityFromDoc,
|
|
30
30
|
checkRepeatedSectionKickersFromDoc,
|
|
31
|
+
checkElementImageDimensions,
|
|
32
|
+
checkElementSmallTouchTarget,
|
|
33
|
+
checkFocusVisible,
|
|
34
|
+
checkHoverOnlyAffordance,
|
|
35
|
+
checkDarkSchemeContrast,
|
|
31
36
|
resolveBackground,
|
|
32
37
|
resolveBorderRadiusPx,
|
|
33
38
|
} from '../../rules/checks.mjs';
|
|
@@ -102,6 +107,8 @@ const STATIC_ELEMENT_RULES = [
|
|
|
102
107
|
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
|
|
103
108
|
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
|
|
104
109
|
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
|
|
110
|
+
{ id: 'image-missing-dimensions', selector: 'img', run: (el, tag, style) => checkElementImageDimensions(el, style) },
|
|
111
|
+
{ id: 'small-touch-target', selector: 'a,button,input,select,summary,[role=button],[role=link],[role=menuitem],[role=tab],[role=switch]', run: (el, tag, style) => checkElementSmallTouchTarget(el, style, tag) },
|
|
105
112
|
];
|
|
106
113
|
|
|
107
114
|
async function detectHtml(filePath, options = {}) {
|
|
@@ -209,6 +216,18 @@ async function detectHtml(filePath, options = {}) {
|
|
|
209
216
|
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
|
|
210
217
|
findings.push(finding(f.id, filePath, f.snippet));
|
|
211
218
|
}
|
|
219
|
+
// Stylesheet-level accessibility rules operate on the concatenated CSS
|
|
220
|
+
// text (<style> blocks + linked sheets) — :hover / :focus-visible /
|
|
221
|
+
// prefers-color-scheme conditions can't be read off a resting element.
|
|
222
|
+
for (const f of runPageCheck('missing-focus-visible', () => checkFocusVisible(cssText))) {
|
|
223
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
224
|
+
}
|
|
225
|
+
for (const f of runPageCheck('hover-only-affordance', () => checkHoverOnlyAffordance(cssText))) {
|
|
226
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
227
|
+
}
|
|
228
|
+
for (const f of runPageCheck('dark-scheme-contrast-blindspot', () => checkDarkSchemeContrast(cssText))) {
|
|
229
|
+
findings.push(finding(f.id, filePath, f.snippet));
|
|
230
|
+
}
|
|
212
231
|
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
|
|
213
232
|
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
|
|
214
233
|
))) {
|