@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 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
  ))) {