@monoes/monodesign 1.2.18 → 1.2.22
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 +3 -3
- package/cli/engine/design-system-checks.mjs +381 -0
- package/cli/engine/design-system-parse.mjs +433 -0
- package/cli/engine/design-system.mjs +20 -736
- package/cli/engine/detect-antipatterns-browser.js +12 -1
- package/cli/engine/engines/regex/detect-text-matchers.mjs +371 -0
- package/cli/engine/engines/regex/detect-text-run.mjs +186 -0
- package/cli/engine/engines/regex/detect-text.mjs +11 -523
- package/cli/engine/engines/static-html/css-cascade-color.mjs +219 -0
- package/cli/engine/engines/static-html/css-cascade-static-data.mjs +138 -0
- package/cli/engine/engines/static-html/css-cascade-static-dom.mjs +132 -0
- package/cli/engine/engines/static-html/css-cascade-static-parse.mjs +393 -0
- package/cli/engine/engines/static-html/css-cascade.mjs +24 -837
- package/cli/engine/registry/antipatterns-provider.mjs +60 -0
- package/cli/engine/registry/antipatterns-quality.mjs +218 -0
- package/cli/engine/registry/antipatterns-slop.mjs +226 -0
- package/cli/engine/registry/antipatterns.mjs +7 -494
- package/cli/engine/rules/checks-background.mjs +155 -0
- package/cli/engine/rules/checks-css-values.mjs +137 -0
- package/cli/engine/rules/checks-dom-effects.mjs +243 -0
- package/cli/engine/rules/checks-dom.mjs +148 -0
- package/cli/engine/rules/checks-element.mjs +286 -0
- package/cli/engine/rules/checks-html.mjs +204 -0
- package/cli/engine/rules/checks-overflow.mjs +263 -0
- package/cli/engine/rules/checks-page.mjs +465 -0
- package/cli/engine/rules/checks-pure.mjs +453 -0
- package/cli/engine/rules/checks-quality.mjs +447 -0
- package/cli/engine/rules/checks-stylesheet.mjs +324 -0
- package/cli/engine/rules/checks.mjs +98 -2939
- package/cli/lib/monodesign-config-consent.mjs +93 -0
- package/cli/lib/monodesign-config-core.mjs +37 -0
- package/cli/lib/monodesign-config-detection.mjs +133 -0
- package/cli/lib/monodesign-config-filter.mjs +198 -0
- package/cli/lib/monodesign-config-ignore-value.mjs +212 -0
- package/cli/lib/monodesign-config.mjs +21 -623
- package/package.json +2 -2
- package/skill/scripts/context-fields.mjs +65 -0
- package/skill/scripts/context-resolve.mjs +425 -0
- package/skill/scripts/context-update.mjs +151 -0
- package/skill/scripts/context-utils.mjs +68 -0
- package/skill/scripts/context-workspace.mjs +245 -0
- package/skill/scripts/context.mjs +17 -891
- package/skill/scripts/hook-admin-config.mjs +152 -0
- package/skill/scripts/hook-admin-ignore.mjs +131 -0
- package/skill/scripts/hook-admin-manifest.mjs +292 -0
- package/skill/scripts/hook-admin.mjs +8 -541
- package/skill/scripts/hook-before-edit-shell.mjs +184 -0
- package/skill/scripts/hook-before-edit.mjs +9 -165
- package/skill/scripts/hook-lib-cache.mjs +145 -0
- package/skill/scripts/hook-lib-config.mjs +292 -0
- package/skill/scripts/hook-lib-detector.mjs +72 -0
- package/skill/scripts/hook-lib-events.mjs +182 -0
- package/skill/scripts/hook-lib-findings.mjs +201 -0
- package/skill/scripts/hook-lib-ignore.mjs +205 -0
- package/skill/scripts/hook-lib-render.mjs +273 -0
- package/skill/scripts/hook-lib-run.mjs +281 -0
- package/skill/scripts/hook-lib-scan-targets.mjs +128 -0
- package/skill/scripts/hook-lib.mjs +71 -1747
- package/skill/scripts/lib/design-parser-colors.mjs +182 -0
- package/skill/scripts/lib/design-parser-extract.mjs +333 -0
- package/skill/scripts/lib/design-parser-sections.mjs +214 -0
- package/skill/scripts/lib/design-parser-yaml.mjs +135 -0
- package/skill/scripts/lib/design-parser.mjs +10 -783
- package/skill/scripts/live/manual-apply-chunk.mjs +151 -0
- package/skill/scripts/live/manual-apply-compact.mjs +122 -0
- package/skill/scripts/live/manual-apply-evidence.mjs +115 -0
- package/skill/scripts/live/manual-apply-log.mjs +179 -0
- package/skill/scripts/live/manual-apply-transaction.mjs +122 -0
- package/skill/scripts/live/manual-apply.mjs +65 -640
- package/skill/scripts/live/svelte-component-accept.mjs +248 -0
- package/skill/scripts/live/svelte-component-css.mjs +230 -0
- package/skill/scripts/live/svelte-component-markup.mjs +104 -0
- package/skill/scripts/live/svelte-component-scaffold.mjs +118 -0
- package/skill/scripts/live/svelte-component-session.mjs +158 -0
- package/skill/scripts/live/svelte-component.mjs +32 -792
- package/skill/scripts/live-accept-mutate.mjs +152 -0
- package/skill/scripts/live-accept-parse.mjs +370 -0
- package/skill/scripts/live-accept-scrub.mjs +59 -0
- package/skill/scripts/live-accept-search.mjs +58 -0
- package/skill/scripts/live-accept.mjs +15 -631
- package/skill/scripts/live-commit-manual-edits-paths.mjs +27 -0
- package/skill/scripts/live-commit-manual-edits-repair.mjs +166 -0
- package/skill/scripts/live-commit-manual-edits-rollback.mjs +231 -0
- package/skill/scripts/live-commit-manual-edits-utils.mjs +167 -0
- package/skill/scripts/live-commit-manual-edits-verify.mjs +356 -0
- package/skill/scripts/live-commit-manual-edits.mjs +24 -881
- package/skill/scripts/live-copy-edit-agent.mjs +16 -551
- package/skill/scripts/live-copy-edit-compact.mjs +137 -0
- package/skill/scripts/live-copy-edit-post-apply.mjs +175 -0
- package/skill/scripts/live-copy-edit-runners.mjs +297 -0
- package/skill/scripts/live-inject-csp.mjs +132 -0
- package/skill/scripts/live-inject-files.mjs +229 -0
- package/skill/scripts/live-inject-tag.mjs +114 -0
- package/skill/scripts/live-inject.mjs +13 -440
- package/skill/scripts/live-server-poll.mjs +232 -0
- package/skill/scripts/live-server-routes.mjs +449 -0
- package/skill/scripts/live-server-state.mjs +361 -0
- package/skill/scripts/live-server.mjs +10 -946
- package/skill/scripts/live-wrap-css.mjs +132 -0
- package/skill/scripts/live-wrap-manual-edit.mjs +153 -0
- package/skill/scripts/live-wrap-search.mjs +247 -0
- package/skill/scripts/live-wrap.mjs +21 -484
- package/skill/scripts/palette-seeds.mjs +401 -0
- package/skill/scripts/palette.mjs +1 -395
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// ─── Section 7: Stylesheet-level accessibility rules ─────────────────────────
|
|
2
|
+
// These operate on raw CSS text (concatenated <style> blocks + linked sheets),
|
|
3
|
+
// not per-element computed style — because :hover / :focus-visible / @media
|
|
4
|
+
// conditions can't be read off a resting element. Shared by the regex, static
|
|
5
|
+
// and browser engines (each hands over its own CSS text).
|
|
6
|
+
|
|
7
|
+
// Split a selector list on top-level commas only — i.e. commas inside a
|
|
8
|
+
// functional pseudo-class's parens (`:is(a, button)`, `:where(a, b)`,
|
|
9
|
+
// `:not(a, b)`, `:has(x, y)`) don't split. A naive `str.split(',')` breaks
|
|
10
|
+
// `:is(a, button):focus { outline: none }` into `:is(a` and `button):focus`,
|
|
11
|
+
// which both selectorTargetsInteractive() and the dark/light selector maps
|
|
12
|
+
// below then treat as two unrelated (and malformed) selectors — producing
|
|
13
|
+
// false-positive missing-focus-visible findings and double-counted rules.
|
|
14
|
+
function splitTopLevelCommas(str) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
let depth = 0, start = 0;
|
|
17
|
+
for (let k = 0; k < str.length; k++) {
|
|
18
|
+
const ch = str[k];
|
|
19
|
+
if (ch === '(') depth++;
|
|
20
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
21
|
+
else if (ch === ',' && depth === 0) {
|
|
22
|
+
parts.push(str.slice(start, k));
|
|
23
|
+
start = k + 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
parts.push(str.slice(start));
|
|
27
|
+
return parts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse flat CSS into { selector, body, dark } tuples, tracking one level of
|
|
31
|
+
// dark-scheme context (a `@media (prefers-color-scheme: dark)` ancestor).
|
|
32
|
+
// @supports / @layer / @container / @scope are transparently descended.
|
|
33
|
+
// @font-face / @keyframes and other at-rules are skipped.
|
|
34
|
+
function collectCssRules(css, dark = false, depth = 0) {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (!css || depth > 6) return out;
|
|
37
|
+
const src = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < src.length) {
|
|
40
|
+
const braceStart = src.indexOf('{', i);
|
|
41
|
+
if (braceStart === -1) break;
|
|
42
|
+
const prelude = src.slice(i, braceStart).trim();
|
|
43
|
+
let d = 1, j = braceStart + 1;
|
|
44
|
+
for (; j < src.length; j++) {
|
|
45
|
+
if (src[j] === '{') d++;
|
|
46
|
+
else if (src[j] === '}') { d--; if (d === 0) break; }
|
|
47
|
+
}
|
|
48
|
+
const body = src.slice(braceStart + 1, j);
|
|
49
|
+
i = j + 1;
|
|
50
|
+
if (!prelude) continue;
|
|
51
|
+
if (prelude.startsWith('@')) {
|
|
52
|
+
const atName = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
|
53
|
+
if (['media', 'supports', 'layer', 'container', 'scope'].includes(atName)) {
|
|
54
|
+
const isDarkMedia = atName === 'media' && /prefers-color-scheme\s*:\s*dark/i.test(prelude);
|
|
55
|
+
out.push(...collectCssRules(body, dark || isDarkMedia, depth + 1));
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const sel of splitTopLevelCommas(prelude).map(s => s.trim()).filter(Boolean)) {
|
|
60
|
+
out.push({ selector: sel, body, dark });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- missing-focus-visible ---
|
|
67
|
+
function selectorTargetsInteractive(sel) {
|
|
68
|
+
return /(^|[\s>+~(,])(a|button|input|select|textarea|summary)(\b|[.:#[])/i.test(sel)
|
|
69
|
+
|| /:focus\b/i.test(sel)
|
|
70
|
+
|| /\[role\s*=\s*["']?button["']?\]/i.test(sel);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function checkFocusVisible(cssText) {
|
|
74
|
+
if (!cssText) return [];
|
|
75
|
+
const rules = collectCssRules(cssText);
|
|
76
|
+
let suppressor = null;
|
|
77
|
+
let hasFocusVisible = false;
|
|
78
|
+
let hasFocusReplacement = false;
|
|
79
|
+
for (const r of rules) {
|
|
80
|
+
const sel = r.selector.toLowerCase();
|
|
81
|
+
const body = r.body.toLowerCase();
|
|
82
|
+
if (/:focus-visible/.test(sel)) {
|
|
83
|
+
// Any :focus-visible styling counts as deliberate focus handling.
|
|
84
|
+
hasFocusVisible = true;
|
|
85
|
+
}
|
|
86
|
+
const suppresses = /outline\s*:\s*(none|0(?:px)?)\b/.test(body)
|
|
87
|
+
|| /outline-style\s*:\s*none/.test(body)
|
|
88
|
+
|| /outline-width\s*:\s*0(?:px)?\b/.test(body);
|
|
89
|
+
if (suppresses && selectorTargetsInteractive(sel) && !/:focus-visible/.test(sel)) {
|
|
90
|
+
suppressor = suppressor || r;
|
|
91
|
+
}
|
|
92
|
+
// A plain :focus rule that draws a visible affordance is a valid fallback.
|
|
93
|
+
if (/:focus\b/.test(sel) && !/:focus-visible/.test(sel)) {
|
|
94
|
+
const olVal = /outline\s*:\s*([^;}]+)/.exec(body);
|
|
95
|
+
const outlineReplaces = olVal && !/^\s*(none|0(?:px)?|inherit|initial|unset)\s*$/.test(olVal[1]);
|
|
96
|
+
if (/box-shadow\s*:/.test(body)
|
|
97
|
+
|| outlineReplaces
|
|
98
|
+
|| /outline-(?:color|width|style)\s*:\s*(?!none|0(?:px)?\b)/.test(body)
|
|
99
|
+
|| /border(?:-\w+)?(?:-color)?\s*:/.test(body)) {
|
|
100
|
+
hasFocusReplacement = true;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (suppressor && !hasFocusVisible && !hasFocusReplacement) {
|
|
105
|
+
return [{ id: 'missing-focus-visible', snippet: `"${suppressor.selector}" removes outline with no :focus-visible replacement` }];
|
|
106
|
+
}
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- hover-only-affordance ---
|
|
111
|
+
function lastCompound(sel) {
|
|
112
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
113
|
+
const last = parts[parts.length - 1] || sel;
|
|
114
|
+
return last.replace(/:{1,2}[a-z-]+(\([^)]*\))?/gi, '').trim().toLowerCase();
|
|
115
|
+
}
|
|
116
|
+
function bodyRevealsVisible(body) {
|
|
117
|
+
const b = body.toLowerCase();
|
|
118
|
+
if (/display\s*:\s*(block|flex|grid|inline(?:-\w+)?|table|list-item)/.test(b)) return true;
|
|
119
|
+
if (/visibility\s*:\s*visible/.test(b)) return true;
|
|
120
|
+
if (/opacity\s*:\s*(?:1(?:\.0+)?|0?\.[1-9]\d*|\.\d*[1-9])\b/.test(b)) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function bodyHidesByDefault(body) {
|
|
124
|
+
const b = body.toLowerCase();
|
|
125
|
+
return /display\s*:\s*none/.test(b)
|
|
126
|
+
|| /visibility\s*:\s*hidden/.test(b)
|
|
127
|
+
|| /opacity\s*:\s*0(?:\.0+)?\b/.test(b);
|
|
128
|
+
}
|
|
129
|
+
function checkHoverOnlyAffordance(cssText) {
|
|
130
|
+
if (!cssText) return [];
|
|
131
|
+
const rules = collectCssRules(cssText);
|
|
132
|
+
const focusTargets = new Set();
|
|
133
|
+
const hiddenTargets = new Set();
|
|
134
|
+
for (const r of rules) {
|
|
135
|
+
const sel = r.selector;
|
|
136
|
+
if (/:(focus|focus-within|focus-visible|active)\b/i.test(sel) && bodyRevealsVisible(r.body)) {
|
|
137
|
+
focusTargets.add(lastCompound(sel));
|
|
138
|
+
}
|
|
139
|
+
if (!/:hover\b/i.test(sel) && bodyHidesByDefault(r.body)) {
|
|
140
|
+
hiddenTargets.add(lastCompound(sel));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const findings = [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
for (const r of rules) {
|
|
146
|
+
const sel = r.selector;
|
|
147
|
+
if (!/:hover\b/i.test(sel)) continue;
|
|
148
|
+
if (!bodyRevealsVisible(r.body)) continue;
|
|
149
|
+
const parts = sel.split(/\s*[>+~]\s*|\s+/).filter(Boolean);
|
|
150
|
+
if (parts.length < 2) continue; // hover on the subject itself — can't hide+reveal
|
|
151
|
+
if (/:hover\b/i.test(parts[parts.length - 1])) continue; // :hover is on the revealed subject
|
|
152
|
+
const target = lastCompound(sel);
|
|
153
|
+
if (!target || !hiddenTargets.has(target)) continue;
|
|
154
|
+
if (focusTargets.has(target)) continue;
|
|
155
|
+
if (seen.has(target)) continue;
|
|
156
|
+
seen.add(target);
|
|
157
|
+
findings.push({ id: 'hover-only-affordance', snippet: `"${target}" is revealed on :hover with no focus/active equivalent` });
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- dark-scheme-contrast-blindspot ---
|
|
163
|
+
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;
|
|
164
|
+
function stripDarkScope(sel) {
|
|
165
|
+
return sel
|
|
166
|
+
.replace(/(?:html|:root|body)?(?:\.dark|\.theme-dark|\.dark-mode)\b/gi, '')
|
|
167
|
+
.replace(/\[data-(?:theme|mode|color-scheme)\s*[~|]?=\s*["']?dark["']?\]/gi, '')
|
|
168
|
+
.replace(/\s*[>+~]\s*/g, ' ')
|
|
169
|
+
.replace(/\s+/g, ' ')
|
|
170
|
+
.trim();
|
|
171
|
+
}
|
|
172
|
+
function normalizeSelKey(sel) {
|
|
173
|
+
return sel.replace(/\s*[>+~]\s*/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
function declHasColor(body) { return /(?:^|[;{\s])color\s*:/i.test(body); }
|
|
176
|
+
function declHasBg(body) { return /(?:^|[;{\s])background(?:-color)?\s*:/i.test(body); }
|
|
177
|
+
function checkDarkSchemeContrast(cssText) {
|
|
178
|
+
if (!cssText) return [];
|
|
179
|
+
const rules = collectCssRules(cssText);
|
|
180
|
+
const hasDarkMedia = /@media[^{]*prefers-color-scheme\s*:\s*dark/i.test(cssText);
|
|
181
|
+
const hasDarkSelectors = rules.some(r => DARK_SCOPE_RE.test(r.selector));
|
|
182
|
+
if (!hasDarkMedia && !hasDarkSelectors) return [];
|
|
183
|
+
|
|
184
|
+
const light = new Map(); // base selector → { color, bg }
|
|
185
|
+
const darkAgg = new Map();
|
|
186
|
+
for (const r of rules) {
|
|
187
|
+
const scopedDark = DARK_SCOPE_RE.test(r.selector);
|
|
188
|
+
const isDark = r.dark || scopedDark;
|
|
189
|
+
if (isDark) {
|
|
190
|
+
const base = normalizeSelKey(scopedDark ? stripDarkScope(r.selector) : r.selector);
|
|
191
|
+
if (!base) continue;
|
|
192
|
+
const e = darkAgg.get(base) || { color: false, bg: false };
|
|
193
|
+
if (declHasColor(r.body)) e.color = true;
|
|
194
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
195
|
+
darkAgg.set(base, e);
|
|
196
|
+
} else {
|
|
197
|
+
const base = normalizeSelKey(r.selector);
|
|
198
|
+
if (!base) continue;
|
|
199
|
+
const e = light.get(base) || { color: false, bg: false };
|
|
200
|
+
if (declHasColor(r.body)) e.color = true;
|
|
201
|
+
if (declHasBg(r.body)) e.bg = true;
|
|
202
|
+
light.set(base, e);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const findings = [];
|
|
207
|
+
for (const [base, d] of darkAgg) {
|
|
208
|
+
if (d.color === d.bg) continue; // sets both or neither — not a split pair
|
|
209
|
+
const lp = light.get(base);
|
|
210
|
+
if (!lp || !(lp.color && lp.bg)) continue; // light scheme must pair both
|
|
211
|
+
const changed = d.bg ? 'background' : 'text color';
|
|
212
|
+
const missing = d.bg ? 'text color' : 'background';
|
|
213
|
+
findings.push({ id: 'dark-scheme-contrast-blindspot', snippet: `"${base}" overrides ${changed} in dark scheme but not ${missing}` });
|
|
214
|
+
}
|
|
215
|
+
return findings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- image-missing-dimensions ---
|
|
219
|
+
function checkImageDimensions({ hasWidthAttr, hasHeightAttr, style }) {
|
|
220
|
+
const s = String(style || '').toLowerCase();
|
|
221
|
+
if (hasWidthAttr && hasHeightAttr) return [];
|
|
222
|
+
if (/aspect-?ratio\s*:/.test(s)) return [];
|
|
223
|
+
const heightM = /(?:^|[;{\s])height\s*:\s*([^;]+)/.exec(s);
|
|
224
|
+
if (heightM && !/^\s*(auto|inherit|initial|unset|0(?:px)?)\s*$/.test(heightM[1])) return [];
|
|
225
|
+
return [{ id: 'image-missing-dimensions', snippet: '<img> without width/height attributes or CSS aspect-ratio (layout shift risk)' }];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function checkElementImageDimensions(el, style) {
|
|
229
|
+
const hasWidthAttr = (el.getAttribute?.('width') ?? el.attribs?.width) != null;
|
|
230
|
+
const hasHeightAttr = (el.getAttribute?.('height') ?? el.attribs?.height) != null;
|
|
231
|
+
const parts = [el.getAttribute?.('style') || el.attribs?.style || ''];
|
|
232
|
+
const ar = style && (style.aspectRatio || style['aspect-ratio']);
|
|
233
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
234
|
+
const h = style?.height;
|
|
235
|
+
if (h && h !== 'auto' && h !== '') parts.push(`height:${h}`);
|
|
236
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function checkElementImageDimensionsDOM(el) {
|
|
240
|
+
// Only <img> can have this problem. The static engine gets this for free —
|
|
241
|
+
// it registers the rule with `selector: 'img'` (engines/static-html/
|
|
242
|
+
// detect-html.mjs) — but the DOM walker calls every check against every
|
|
243
|
+
// element, so the tag filter has to live here. Without it this rule fired on
|
|
244
|
+
// <head>, <meta>, <title> and <style>: a clean 84-line fixture reported 43
|
|
245
|
+
// findings, all of them "an <img> ships without width and height".
|
|
246
|
+
if (el.tagName !== 'IMG') return [];
|
|
247
|
+
const hasWidthAttr = el.getAttribute('width') != null;
|
|
248
|
+
const hasHeightAttr = el.getAttribute('height') != null;
|
|
249
|
+
// Browser computed `height` always resolves to a used px value after layout,
|
|
250
|
+
// so it can't tell a reserved height from a rendered one — only use inline
|
|
251
|
+
// style + the computed aspect-ratio (which stays 'auto' unless authored).
|
|
252
|
+
const parts = [el.getAttribute('style') || ''];
|
|
253
|
+
const style = getComputedStyle(el);
|
|
254
|
+
const ar = style.aspectRatio || '';
|
|
255
|
+
if (ar && ar !== 'auto') parts.push(`aspect-ratio:${ar}`);
|
|
256
|
+
return checkImageDimensions({ hasWidthAttr, hasHeightAttr, style: parts.join(';') });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- small-touch-target ---
|
|
260
|
+
const CLICKABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'checkbox', 'radio', 'image']);
|
|
261
|
+
const CLICKABLE_ROLES = new Set(['button', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'tab', 'switch', 'checkbox', 'radio', 'option']);
|
|
262
|
+
function isClickableControl(tag, role, href, inputType) {
|
|
263
|
+
if (tag === 'button' || tag === 'select' || tag === 'summary') return true;
|
|
264
|
+
if (tag === 'a') return href != null && href !== '';
|
|
265
|
+
if (tag === 'input') return CLICKABLE_INPUT_TYPES.has(String(inputType || '').toLowerCase());
|
|
266
|
+
if (role && CLICKABLE_ROLES.has(role)) return true;
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
function checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse }) {
|
|
270
|
+
if (!isClickableControl(tag, role, href, inputType)) return [];
|
|
271
|
+
if (isInlineLinkInProse) return [];
|
|
272
|
+
if (!(widthPx > 0) || !(heightPx > 0)) return [];
|
|
273
|
+
if (widthPx >= 44 && heightPx >= 44) return [];
|
|
274
|
+
const roleLabel = role && !['button', 'a', 'input', 'select'].includes(tag) ? `[role=${role}]` : '';
|
|
275
|
+
return [{ id: 'small-touch-target', snippet: `<${tag}>${roleLabel} renders ${Math.round(widthPx)}x${Math.round(heightPx)}px (min 44x44)` }];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const PROSE_ANCESTOR_SELECTOR = 'p,li,blockquote,figcaption,dd,dt';
|
|
279
|
+
function checkElementSmallTouchTarget(el, style, tag) {
|
|
280
|
+
// Static path: only provable when width AND height are explicit px lengths
|
|
281
|
+
// (jsdom performs no layout; rem/%/auto can't be resolved to a real box).
|
|
282
|
+
const wRaw = style.width || '';
|
|
283
|
+
const hRaw = style.height || '';
|
|
284
|
+
if (!/px\s*$/.test(wRaw) || !/px\s*$/.test(hRaw)) return [];
|
|
285
|
+
const widthPx = parseFloat(wRaw);
|
|
286
|
+
const heightPx = parseFloat(hRaw);
|
|
287
|
+
const href = el.getAttribute?.('href');
|
|
288
|
+
const role = (el.getAttribute?.('role') || '').toLowerCase();
|
|
289
|
+
const inputType = el.getAttribute?.('type') || '';
|
|
290
|
+
// An <a> is a prose text link unless it is explicitly laid out as a block
|
|
291
|
+
// (button-like). jsdom returns '' for the default inline display, so key
|
|
292
|
+
// off "not blockish" rather than "== inline".
|
|
293
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
294
|
+
const inProse = !!el.closest?.(PROSE_ANCESTOR_SELECTOR);
|
|
295
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
296
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx, heightPx, isInlineLinkInProse });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function checkElementSmallTouchTargetDOM(el) {
|
|
300
|
+
const tag = el.tagName.toLowerCase();
|
|
301
|
+
const rect = el.getBoundingClientRect();
|
|
302
|
+
if (!(rect.width > 0) || !(rect.height > 0)) return [];
|
|
303
|
+
const href = el.getAttribute('href');
|
|
304
|
+
const role = (el.getAttribute('role') || '').toLowerCase();
|
|
305
|
+
const inputType = el.getAttribute('type') || '';
|
|
306
|
+
const style = getComputedStyle(el);
|
|
307
|
+
const isBlockish = /^(block|flex|grid|inline-block|inline-flex|table)/.test(style.display || '');
|
|
308
|
+
const inProse = !!el.closest?.(PROSE_ANCESTOR_SELECTOR);
|
|
309
|
+
const isInlineLinkInProse = tag === 'a' && !isBlockish && inProse;
|
|
310
|
+
return checkSmallTouchTarget({ tag, role, href, inputType, widthPx: rect.width, heightPx: rect.height, isInlineLinkInProse });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export {
|
|
314
|
+
collectCssRules,
|
|
315
|
+
checkFocusVisible,
|
|
316
|
+
checkHoverOnlyAffordance,
|
|
317
|
+
checkDarkSchemeContrast,
|
|
318
|
+
checkImageDimensions,
|
|
319
|
+
checkElementImageDimensions,
|
|
320
|
+
checkElementImageDimensionsDOM,
|
|
321
|
+
checkSmallTouchTarget,
|
|
322
|
+
checkElementSmallTouchTarget,
|
|
323
|
+
checkElementSmallTouchTargetDOM,
|
|
324
|
+
};
|