monomind 2.5.2 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/packages/@monomind/cli/.claude/agents/github/repo-architect.md +3 -3
  4. package/packages/@monomind/cli/.claude/agents/github/sync-coordinator.md +2 -2
  5. package/packages/@monomind/cli/.claude/agents/specialists/integration-architect.md +4 -4
  6. package/packages/@monomind/cli/.claude/commands/github/repo-architect.md +4 -4
  7. package/packages/@monomind/cli/.claude/commands/github/sync-coordinator.md +2 -2
  8. package/packages/@monomind/cli/.claude/helpers/event-logger.cjs +2 -2
  9. package/packages/@monomind/cli/.claude/helpers/extras-registry.json +1 -1
  10. package/packages/@monomind/cli/.claude/helpers/handlers/agent-start-handler.cjs +2 -2
  11. package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +2 -2
  12. package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +6 -1
  13. package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +1 -1
  14. package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +1 -1
  15. package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +11 -11
  16. package/packages/@monomind/cli/.claude/helpers/handlers/task-handler.cjs +2 -2
  17. package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +8 -8
  18. package/packages/@monomind/cli/.claude/skills/mastermind-techport/SKILL.md +1 -1
  19. package/packages/@monomind/cli/.claude/skills/monodesign/SKILL.md +2 -2
  20. package/packages/@monomind/cli/.claude/skills/monodesign/reference/antipatterns-catalog.md +47 -2
  21. package/packages/@monomind/cli/.claude/skills/monodesign/reference/critique.md +1 -1
  22. package/packages/@monomind/cli/.claude/skills/monodesign/reference/live.md +0 -8
  23. package/packages/@monomind/cli/.claude/skills/monodesign/reference/polish.md +2 -2
  24. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context-signals.mjs +39 -6
  25. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context.mjs +0 -1
  26. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/critique-storage.mjs +175 -2
  27. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detect-csp.mjs +0 -1
  28. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detect.mjs +0 -1
  29. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/browser/injected/index.mjs +25 -1
  30. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/cli/main.mjs +0 -1
  31. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/design-system.mjs +0 -1
  32. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/detect-antipatterns-browser.js +356 -1
  33. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/detect-antipatterns.mjs +0 -1
  34. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/browser/detect-url.mjs +0 -1
  35. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/browser/drivers.mjs +0 -1
  36. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/regex/detect-text.mjs +63 -1
  37. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/static-html/css-cascade.mjs +0 -1
  38. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/static-html/detect-html.mjs +19 -1
  39. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -1
  40. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/findings.mjs +0 -1
  41. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/css-regions.mjs +181 -0
  42. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/fixers.mjs +373 -0
  43. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/index.mjs +426 -0
  44. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/node/file-system.mjs +0 -1
  45. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/profile/profiler.mjs +0 -1
  46. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/registry/antipatterns.mjs +49 -1
  47. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/rules/checks.mjs +292 -1
  48. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/color.mjs +0 -1
  49. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/constants.mjs +0 -1
  50. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/fonts.mjs +0 -1
  51. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/inline-ignores.mjs +0 -1
  52. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/page.mjs +0 -1
  53. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-admin.mjs +0 -1
  54. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-before-edit.mjs +0 -1
  55. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-lib.mjs +0 -1
  56. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook.mjs +0 -1
  57. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/design-parser.mjs +0 -1
  58. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/is-generated.mjs +0 -1
  59. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/monodesign-config.mjs +0 -1
  60. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/monodesign-paths.mjs +0 -1
  61. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/provider.mjs +0 -1
  62. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/target-args.mjs +0 -1
  63. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/browser-script-parts.mjs +0 -1
  64. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/completion.mjs +0 -1
  65. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/event-validation.mjs +0 -1
  66. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/insert-ui.mjs +0 -1
  67. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-apply.mjs +0 -1
  68. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-edit-routes.mjs +0 -1
  69. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-edits-buffer.mjs +0 -1
  70. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/session-store.mjs +5 -2
  71. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/svelte-component.mjs +0 -1
  72. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/sveltekit-adapter.mjs +0 -1
  73. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/ui-core.mjs +0 -1
  74. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/vocabulary.mjs +0 -1
  75. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-accept.mjs +0 -1
  76. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser-dom.js +0 -1
  77. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser-session.js +0 -1
  78. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser.js +0 -1
  79. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-commit-manual-edits.mjs +0 -1
  80. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-complete.mjs +0 -1
  81. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-copy-edit-agent.mjs +0 -1
  82. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-discard-manual-edits.mjs +0 -1
  83. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-inject.mjs +0 -1
  84. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-insert.mjs +0 -1
  85. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-manual-edit-evidence.mjs +0 -1
  86. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-poll.mjs +0 -1
  87. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-resume.mjs +0 -1
  88. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +0 -1
  89. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-status.mjs +0 -1
  90. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-target.mjs +0 -1
  91. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-wrap.mjs +0 -1
  92. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live.mjs +0 -1
  93. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/modern-screenshot.umd.js +0 -1
  94. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/palette.mjs +0 -1
  95. package/packages/@monomind/cli/.claude/skills/monodesign/scripts/pin.mjs +0 -1
  96. package/packages/@monomind/cli/README.md +1 -1
  97. package/packages/@monomind/cli/dist/src/commands/design-detect.js +62 -1
  98. package/packages/@monomind/cli/dist/src/commands/design-palette.d.ts +0 -1
  99. package/packages/@monomind/cli/dist/src/commands/design-palette.js +0 -1
  100. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.d.ts +1 -1
  101. package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +1 -1
  102. package/packages/@monomind/cli/dist/src/commands/hooks-workers.d.ts +1 -1
  103. package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +8 -8
  104. package/packages/@monomind/cli/dist/src/commands/init.js +2 -2
  105. package/packages/@monomind/cli/dist/src/index.d.ts +1 -1
  106. package/packages/@monomind/cli/dist/src/index.js +3 -3
  107. package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +5 -5
  108. package/packages/@monomind/cli/dist/src/init/executor.js +3 -3
  109. package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +3 -1
  110. package/packages/@monomind/cli/dist/src/mcp-server.js +2 -2
  111. package/packages/@monomind/cli/dist/src/mcp-tools/hooks-intelligence.d.ts +1 -1
  112. package/packages/@monomind/cli/dist/src/mcp-tools/hooks-intelligence.js +1 -1
  113. package/packages/@monomind/cli/dist/src/mcp-tools/hooks-routing.js +2 -2
  114. package/packages/@monomind/cli/dist/src/mcp-tools/hooks-tools.js +1 -1
  115. package/packages/@monomind/cli/dist/src/memory/memory-kg.d.ts +5 -4
  116. package/packages/@monomind/cli/dist/src/memory/memory-kg.js +5 -4
  117. package/packages/@monomind/cli/dist/src/routing/embed-worker.js +1 -1
  118. package/packages/@monomind/cli/dist/src/routing/llm-caller.js +1 -1
  119. package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +1 -1
  120. package/packages/@monomind/cli/dist/src/ui/dashboard.html +22 -20
  121. package/packages/@monomind/cli/dist/src/update/checker.js +3 -3
  122. package/packages/@monomind/cli/package.json +5 -5
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  /**
3
2
  * Anti-Pattern Browser Detector for Monodesign
4
3
  * Copyright (c) 2026 Paul Bakaus
@@ -491,6 +490,55 @@ const ANTIPATTERNS = [
491
490
  skillGuideline: 'font size outside the project design system',
492
491
  },
493
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
+
494
542
  // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
495
543
  {
496
544
  id: 'gpt-thin-border-wide-shadow',
@@ -3304,6 +3352,288 @@ function checkElementTextOverflowDOM(el) {
3304
3352
  return [];
3305
3353
  }
3306
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
+
3307
3637
  // --- cli/engine/browser/injected/index.mjs ---
3308
3638
  const IS_BROWSER = typeof window !== 'undefined';
3309
3639
 
@@ -4795,6 +5125,8 @@ if (IS_BROWSER) {
4795
5125
  ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4796
5126
  ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4797
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 })),
4798
5130
  ...checkElementDesignSystemDOM(el, designSystem, designSeen),
4799
5131
  ].filter(f => _ruleOk(f.type));
4800
5132
 
@@ -4854,6 +5186,29 @@ if (IS_BROWSER) {
4854
5186
  addBrowserFindings(groupMap, document.body, creamFindings);
4855
5187
  }
4856
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
+
4857
5212
  // Regex-on-HTML checks (shared with Node)
4858
5213
  // Clone the document and strip monodesign-live overlay nodes before the
4859
5214
  // regex scan, so the inspector's own inline styles (transitions on top/
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
3
2
 
4
3
  /**
5
4
  * Anti-Pattern Detector for Monodesign
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  //
3
2
  // Browser driver seam for URL detection.
4
3
  //
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
3
2
  import { isNeutralColor } from '../../shared/color.mjs';
4
3
  import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
@@ -8,6 +7,11 @@ import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
8
7
  import { finding } from '../../findings.mjs';
9
8
  import { filterByProviders } from '../../registry/antipatterns.mjs';
10
9
  import { profileFindings, profileStep } from '../../profile/profiler.mjs';
10
+ import {
11
+ checkFocusVisible,
12
+ checkHoverOnlyAffordance,
13
+ checkDarkSchemeContrast,
14
+ } from '../../rules/checks.mjs';
11
15
 
12
16
  // ---------------------------------------------------------------------------
13
17
  // Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
@@ -166,6 +170,17 @@ const REGEX_MATCHERS = [
166
170
  { id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
167
171
  test: (m) => !/\bsrc\s*=/i.test(m[0]),
168
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) },
169
184
  ];
170
185
 
171
186
  const REGEX_ANALYZERS = [
@@ -465,6 +480,48 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
465
480
  return findings;
466
481
  }
467
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
+
468
525
  function detectText(content, filePath, options = {}) {
469
526
  const profile = options?.profile;
470
527
  const findings = [];
@@ -556,6 +613,10 @@ function detectText(content, filePath, options = {}) {
556
613
  }
557
614
  }
558
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
+
559
620
  const byProvider = filterByProviders(deduped, options?.providers);
560
621
  // Inline `monodesign-disable*` waivers travel with the file; honor them unless
561
622
  // explicitly bypassed (`--no-config` / `--no-inline-ignores`).
@@ -570,5 +631,6 @@ export {
570
631
  extractCSSinJS,
571
632
  runRegexMatchers,
572
633
  runTextContentAnalyzers,
634
+ runStylesheetAnalyzers,
573
635
  detectText,
574
636
  };
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
 
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
 
@@ -29,6 +28,11 @@ import {
29
28
  checkPageLayout,
30
29
  checkPageQualityFromDoc,
31
30
  checkRepeatedSectionKickersFromDoc,
31
+ checkElementImageDimensions,
32
+ checkElementSmallTouchTarget,
33
+ checkFocusVisible,
34
+ checkHoverOnlyAffordance,
35
+ checkDarkSchemeContrast,
32
36
  resolveBackground,
33
37
  resolveBorderRadiusPx,
34
38
  } from '../../rules/checks.mjs';
@@ -103,6 +107,8 @@ const STATIC_ELEMENT_RULES = [
103
107
  { id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
104
108
  { id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
105
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) },
106
112
  ];
107
113
 
108
114
  async function detectHtml(filePath, options = {}) {
@@ -210,6 +216,18 @@ async function detectHtml(filePath, options = {}) {
210
216
  for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
211
217
  findings.push(finding(f.id, filePath, f.snippet));
212
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
+ }
213
231
  for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
214
232
  item.id !== 'bounce-easing' && item.id !== 'layout-transition'
215
233
  ))) {
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  //
3
2
  // Visual-contrast pixel fallback. `page` is the driver page abstraction from
4
3
  // ../browser/drivers.mjs (a raw puppeteer Page also satisfies it): it only
@@ -1,4 +1,3 @@
1
- // Pattern adapted from impeccable (Apache-2.0) — rebranded for monomind
2
1
  import { getAntipattern } from './registry/antipatterns.mjs';
3
2
 
4
3
  function getAP(id) {