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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/@monomind/cli/.claude/agents/github/repo-architect.md +3 -3
- package/packages/@monomind/cli/.claude/agents/github/sync-coordinator.md +2 -2
- package/packages/@monomind/cli/.claude/agents/specialists/integration-architect.md +4 -4
- package/packages/@monomind/cli/.claude/commands/github/repo-architect.md +4 -4
- package/packages/@monomind/cli/.claude/commands/github/sync-coordinator.md +2 -2
- package/packages/@monomind/cli/.claude/helpers/event-logger.cjs +2 -2
- package/packages/@monomind/cli/.claude/helpers/extras-registry.json +1 -1
- package/packages/@monomind/cli/.claude/helpers/handlers/agent-start-handler.cjs +2 -2
- package/packages/@monomind/cli/.claude/helpers/handlers/edit-handler.cjs +2 -2
- package/packages/@monomind/cli/.claude/helpers/handlers/gates-handler.cjs +6 -1
- package/packages/@monomind/cli/.claude/helpers/handlers/route-handler.cjs +1 -1
- package/packages/@monomind/cli/.claude/helpers/handlers/session-handler.cjs +1 -1
- package/packages/@monomind/cli/.claude/helpers/handlers/session-restore-handler.cjs +11 -11
- package/packages/@monomind/cli/.claude/helpers/handlers/task-handler.cjs +2 -2
- package/packages/@monomind/cli/.claude/helpers/hook-handler.cjs +8 -8
- package/packages/@monomind/cli/.claude/skills/mastermind-techport/SKILL.md +1 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/SKILL.md +2 -2
- package/packages/@monomind/cli/.claude/skills/monodesign/reference/antipatterns-catalog.md +47 -2
- package/packages/@monomind/cli/.claude/skills/monodesign/reference/critique.md +1 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/reference/live.md +0 -8
- package/packages/@monomind/cli/.claude/skills/monodesign/reference/polish.md +2 -2
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context-signals.mjs +39 -6
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/context.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/critique-storage.mjs +175 -2
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detect-csp.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detect.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/browser/injected/index.mjs +25 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/cli/main.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/design-system.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/detect-antipatterns-browser.js +356 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/detect-antipatterns.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/browser/detect-url.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/browser/drivers.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/regex/detect-text.mjs +63 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/static-html/css-cascade.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/static-html/detect-html.mjs +19 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/findings.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/css-regions.mjs +181 -0
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/fixers.mjs +373 -0
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/fix/index.mjs +426 -0
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/node/file-system.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/profile/profiler.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/registry/antipatterns.mjs +49 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/rules/checks.mjs +292 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/color.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/constants.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/fonts.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/inline-ignores.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/detector/shared/page.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-admin.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-before-edit.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook-lib.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/hook.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/design-parser.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/is-generated.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/monodesign-config.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/monodesign-paths.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/provider.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/lib/target-args.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/browser-script-parts.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/completion.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/event-validation.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/insert-ui.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-apply.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-edit-routes.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/manual-edits-buffer.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/session-store.mjs +5 -2
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/svelte-component.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/sveltekit-adapter.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/ui-core.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live/vocabulary.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-accept.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser-dom.js +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser-session.js +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-browser.js +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-commit-manual-edits.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-complete.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-copy-edit-agent.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-discard-manual-edits.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-inject.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-insert.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-manual-edit-evidence.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-poll.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-resume.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-server.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-status.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-target.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live-wrap.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/live.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/modern-screenshot.umd.js +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/palette.mjs +0 -1
- package/packages/@monomind/cli/.claude/skills/monodesign/scripts/pin.mjs +0 -1
- package/packages/@monomind/cli/README.md +1 -1
- package/packages/@monomind/cli/dist/src/commands/design-detect.js +62 -1
- package/packages/@monomind/cli/dist/src/commands/design-palette.d.ts +0 -1
- package/packages/@monomind/cli/dist/src/commands/design-palette.js +0 -1
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/commands/doctor-project-checks.js +1 -1
- package/packages/@monomind/cli/dist/src/commands/hooks-workers.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/commands/hooks-workers.js +8 -8
- package/packages/@monomind/cli/dist/src/commands/init.js +2 -2
- package/packages/@monomind/cli/dist/src/index.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/index.js +3 -3
- package/packages/@monomind/cli/dist/src/init/claudemd-generator.js +5 -5
- package/packages/@monomind/cli/dist/src/init/executor.js +3 -3
- package/packages/@monomind/cli/dist/src/knowledge/document-pipeline.js +3 -1
- package/packages/@monomind/cli/dist/src/mcp-server.js +2 -2
- package/packages/@monomind/cli/dist/src/mcp-tools/hooks-intelligence.d.ts +1 -1
- package/packages/@monomind/cli/dist/src/mcp-tools/hooks-intelligence.js +1 -1
- package/packages/@monomind/cli/dist/src/mcp-tools/hooks-routing.js +2 -2
- package/packages/@monomind/cli/dist/src/mcp-tools/hooks-tools.js +1 -1
- package/packages/@monomind/cli/dist/src/memory/memory-kg.d.ts +5 -4
- package/packages/@monomind/cli/dist/src/memory/memory-kg.js +5 -4
- package/packages/@monomind/cli/dist/src/routing/embed-worker.js +1 -1
- package/packages/@monomind/cli/dist/src/routing/llm-caller.js +1 -1
- package/packages/@monomind/cli/dist/src/routing/route-layer-factory.js +1 -1
- package/packages/@monomind/cli/dist/src/ui/dashboard.html +22 -20
- package/packages/@monomind/cli/dist/src/update/checker.js +3 -3
- 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,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
|
|
|
@@ -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
|
))) {
|