@wireweave/ux-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/dist/index.cjs +1236 -0
- package/dist/index.d.cts +187 -0
- package/dist/index.d.ts +187 -0
- package/dist/index.js +1202 -0
- package/package.json +54 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1236 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
allRules: () => allRules,
|
|
24
|
+
formatUXResult: () => formatUXResult,
|
|
25
|
+
getRulesForCategories: () => getRulesForCategories,
|
|
26
|
+
getUXIssues: () => getUXIssues,
|
|
27
|
+
getUXScore: () => getUXScore,
|
|
28
|
+
isUXValid: () => isUXValid,
|
|
29
|
+
rulesByCategory: () => rulesByCategory,
|
|
30
|
+
validateUX: () => validateUX
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(src_exports);
|
|
33
|
+
|
|
34
|
+
// src/rules/accessibility.ts
|
|
35
|
+
var inputRequiresLabel = {
|
|
36
|
+
id: "a11y-input-label",
|
|
37
|
+
category: "accessibility",
|
|
38
|
+
severity: "error",
|
|
39
|
+
name: "Input requires label",
|
|
40
|
+
description: "All input fields must have a label for screen reader users",
|
|
41
|
+
appliesTo: ["Input", "Textarea", "Select"],
|
|
42
|
+
check: (node, context) => {
|
|
43
|
+
const hasLabel = "label" in node && node.label;
|
|
44
|
+
const hasPlaceholder = "placeholder" in node && node.placeholder;
|
|
45
|
+
if (!hasLabel) {
|
|
46
|
+
return {
|
|
47
|
+
ruleId: "a11y-input-label",
|
|
48
|
+
category: "accessibility",
|
|
49
|
+
severity: hasPlaceholder ? "warning" : "error",
|
|
50
|
+
message: `${node.type} is missing a label`,
|
|
51
|
+
description: "Screen readers rely on labels to describe form fields to users",
|
|
52
|
+
suggestion: hasPlaceholder ? "Add a label attribute. Placeholder alone is not sufficient for accessibility" : "Add a label attribute to describe this field",
|
|
53
|
+
path: context.path,
|
|
54
|
+
nodeType: node.type,
|
|
55
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var imageRequiresAlt = {
|
|
62
|
+
id: "a11y-image-alt",
|
|
63
|
+
category: "accessibility",
|
|
64
|
+
severity: "warning",
|
|
65
|
+
name: "Image requires alt text",
|
|
66
|
+
description: "Images should have alt text for screen reader users",
|
|
67
|
+
appliesTo: ["Image"],
|
|
68
|
+
check: (node, context) => {
|
|
69
|
+
const hasAlt = "alt" in node && node.alt;
|
|
70
|
+
if (!hasAlt) {
|
|
71
|
+
return {
|
|
72
|
+
ruleId: "a11y-image-alt",
|
|
73
|
+
category: "accessibility",
|
|
74
|
+
severity: "warning",
|
|
75
|
+
message: "Image is missing alt text",
|
|
76
|
+
description: "Screen readers use alt text to describe images to users",
|
|
77
|
+
suggestion: "Add an alt attribute describing the image content",
|
|
78
|
+
path: context.path,
|
|
79
|
+
nodeType: node.type,
|
|
80
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var iconButtonRequiresLabel = {
|
|
87
|
+
id: "a11y-icon-button-label",
|
|
88
|
+
category: "accessibility",
|
|
89
|
+
severity: "error",
|
|
90
|
+
name: "Icon-only button requires label",
|
|
91
|
+
description: "Buttons with only an icon must have an accessible label",
|
|
92
|
+
appliesTo: ["Button"],
|
|
93
|
+
check: (node, context) => {
|
|
94
|
+
const hasIcon = "icon" in node && node.icon;
|
|
95
|
+
const hasContent = "content" in node && node.content && String(node.content).trim();
|
|
96
|
+
if (hasIcon && !hasContent) {
|
|
97
|
+
return {
|
|
98
|
+
ruleId: "a11y-icon-button-label",
|
|
99
|
+
category: "accessibility",
|
|
100
|
+
severity: "error",
|
|
101
|
+
message: "Icon-only button is missing accessible text",
|
|
102
|
+
description: "Screen readers cannot describe icon-only buttons without text",
|
|
103
|
+
suggestion: "Add text content or aria-label to describe the button action",
|
|
104
|
+
path: context.path,
|
|
105
|
+
nodeType: node.type,
|
|
106
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var linkRequiresDescriptiveText = {
|
|
113
|
+
id: "a11y-link-text",
|
|
114
|
+
category: "accessibility",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
name: "Link requires descriptive text",
|
|
117
|
+
description: "Links should have descriptive text that indicates where they lead",
|
|
118
|
+
appliesTo: ["Link"],
|
|
119
|
+
check: (node, context) => {
|
|
120
|
+
const content = "content" in node ? String(node.content || "").toLowerCase() : "";
|
|
121
|
+
const genericTexts = ["click here", "here", "read more", "more", "link"];
|
|
122
|
+
if (genericTexts.includes(content.trim())) {
|
|
123
|
+
return {
|
|
124
|
+
ruleId: "a11y-link-text",
|
|
125
|
+
category: "accessibility",
|
|
126
|
+
severity: "warning",
|
|
127
|
+
message: "Link has generic text that is not descriptive",
|
|
128
|
+
description: 'Screen reader users often navigate by links, and generic text like "click here" is not helpful',
|
|
129
|
+
suggestion: `Replace "${content}" with descriptive text that indicates the link destination`,
|
|
130
|
+
path: context.path,
|
|
131
|
+
nodeType: node.type,
|
|
132
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var headingHierarchy = {
|
|
139
|
+
id: "a11y-heading-hierarchy",
|
|
140
|
+
category: "accessibility",
|
|
141
|
+
severity: "warning",
|
|
142
|
+
name: "Heading hierarchy should be sequential",
|
|
143
|
+
description: "Heading levels should not skip (e.g., h1 to h3)",
|
|
144
|
+
appliesTo: ["Title"],
|
|
145
|
+
check: (node, context) => {
|
|
146
|
+
const level = "level" in node ? Number(node.level) : 1;
|
|
147
|
+
let prevHeadingLevel = 0;
|
|
148
|
+
for (let i = 0; i < context.index; i++) {
|
|
149
|
+
const sibling = context.siblings[i];
|
|
150
|
+
if (sibling && sibling.type === "Title") {
|
|
151
|
+
prevHeadingLevel = "level" in sibling ? Number(sibling.level) : 1;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (prevHeadingLevel > 0 && level > prevHeadingLevel + 1) {
|
|
155
|
+
return {
|
|
156
|
+
ruleId: "a11y-heading-hierarchy",
|
|
157
|
+
category: "accessibility",
|
|
158
|
+
severity: "warning",
|
|
159
|
+
message: `Heading level skipped from h${prevHeadingLevel} to h${level}`,
|
|
160
|
+
description: "Screen reader users rely on heading hierarchy to understand page structure",
|
|
161
|
+
suggestion: `Use h${prevHeadingLevel + 1} instead of h${level}, or add intermediate headings`,
|
|
162
|
+
path: context.path,
|
|
163
|
+
nodeType: node.type,
|
|
164
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
var accessibilityRules = [
|
|
171
|
+
inputRequiresLabel,
|
|
172
|
+
imageRequiresAlt,
|
|
173
|
+
iconButtonRequiresLabel,
|
|
174
|
+
linkRequiresDescriptiveText,
|
|
175
|
+
headingHierarchy
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
// src/rules/form.ts
|
|
179
|
+
var formRequiresSubmit = {
|
|
180
|
+
id: "form-submit-button",
|
|
181
|
+
category: "form",
|
|
182
|
+
severity: "warning",
|
|
183
|
+
name: "Form should have submit button",
|
|
184
|
+
description: "Forms with input fields should have a clear submit action",
|
|
185
|
+
appliesTo: ["Card", "Section", "Modal", "Main"],
|
|
186
|
+
check: (node, context) => {
|
|
187
|
+
if (!("children" in node) || !Array.isArray(node.children)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
let hasInputs = false;
|
|
191
|
+
let hasSubmitButton = false;
|
|
192
|
+
function walkChildren(children) {
|
|
193
|
+
for (const child of children) {
|
|
194
|
+
if (["Input", "Textarea", "Select", "Checkbox", "Radio"].includes(child.type)) {
|
|
195
|
+
hasInputs = true;
|
|
196
|
+
}
|
|
197
|
+
if (child.type === "Button") {
|
|
198
|
+
const isPrimary = "primary" in child && child.primary;
|
|
199
|
+
const content = "content" in child ? String(child.content || "").toLowerCase() : "";
|
|
200
|
+
const submitWords = ["submit", "save", "send", "create", "add", "update", "confirm", "ok", "done"];
|
|
201
|
+
if (isPrimary || submitWords.some((w) => content.includes(w))) {
|
|
202
|
+
hasSubmitButton = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if ("children" in child && Array.isArray(child.children)) {
|
|
206
|
+
walkChildren(child.children);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
walkChildren(node.children);
|
|
211
|
+
if (hasInputs && !hasSubmitButton) {
|
|
212
|
+
return {
|
|
213
|
+
ruleId: "form-submit-button",
|
|
214
|
+
category: "form",
|
|
215
|
+
severity: "warning",
|
|
216
|
+
message: "Form area has inputs but no clear submit button",
|
|
217
|
+
description: "Users need a clear way to submit form data",
|
|
218
|
+
suggestion: 'Add a primary button with a clear action label (e.g., "Submit", "Save")',
|
|
219
|
+
path: context.path,
|
|
220
|
+
nodeType: node.type,
|
|
221
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
var requiredFieldIndicator = {
|
|
228
|
+
id: "form-required-indicator",
|
|
229
|
+
category: "form",
|
|
230
|
+
severity: "info",
|
|
231
|
+
name: "Required fields should be clearly marked",
|
|
232
|
+
description: "Users should know which fields are required before filling out a form",
|
|
233
|
+
appliesTo: ["Input", "Textarea", "Select"],
|
|
234
|
+
check: (node, context) => {
|
|
235
|
+
const isRequired = "required" in node && node.required;
|
|
236
|
+
const label = "label" in node ? String(node.label || "") : "";
|
|
237
|
+
if (isRequired && label && !label.includes("*") && !label.toLowerCase().includes("required")) {
|
|
238
|
+
return {
|
|
239
|
+
ruleId: "form-required-indicator",
|
|
240
|
+
category: "form",
|
|
241
|
+
severity: "info",
|
|
242
|
+
message: "Required field label does not indicate it is required",
|
|
243
|
+
description: "Users should see visual indication that a field is required",
|
|
244
|
+
suggestion: 'Add an asterisk (*) to the label or include "required" in the label text',
|
|
245
|
+
path: context.path,
|
|
246
|
+
nodeType: node.type,
|
|
247
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
var passwordConfirmation = {
|
|
254
|
+
id: "form-password-confirm",
|
|
255
|
+
category: "form",
|
|
256
|
+
severity: "info",
|
|
257
|
+
name: "Password field may need confirmation",
|
|
258
|
+
description: "Password fields in registration forms should have a confirmation field",
|
|
259
|
+
appliesTo: ["Input"],
|
|
260
|
+
check: (node, context) => {
|
|
261
|
+
const inputType = "inputType" in node ? node.inputType : "";
|
|
262
|
+
if (inputType !== "password") return null;
|
|
263
|
+
const label = "label" in node ? String(node.label || "").toLowerCase() : "";
|
|
264
|
+
const isConfirmField = label.includes("confirm") || label.includes("repeat") || label.includes("retype");
|
|
265
|
+
if (isConfirmField) return null;
|
|
266
|
+
let hasConfirmField = false;
|
|
267
|
+
for (const sibling of context.siblings) {
|
|
268
|
+
if (sibling.type === "Input" && "inputType" in sibling && sibling.inputType === "password") {
|
|
269
|
+
const siblingLabel = "label" in sibling ? String(sibling.label || "").toLowerCase() : "";
|
|
270
|
+
if (siblingLabel.includes("confirm") || siblingLabel.includes("repeat") || siblingLabel.includes("retype")) {
|
|
271
|
+
hasConfirmField = true;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const parentLabel = context.parent && "title" in context.parent ? String(context.parent.title || "").toLowerCase() : "";
|
|
277
|
+
const isRegistration = parentLabel.includes("sign up") || parentLabel.includes("register") || parentLabel.includes("create account");
|
|
278
|
+
if (isRegistration && !hasConfirmField) {
|
|
279
|
+
return {
|
|
280
|
+
ruleId: "form-password-confirm",
|
|
281
|
+
category: "form",
|
|
282
|
+
severity: "info",
|
|
283
|
+
message: "Registration form password field has no confirmation field",
|
|
284
|
+
description: "Users may mistype their password without a confirmation field",
|
|
285
|
+
suggestion: 'Add a "Confirm Password" field to prevent typos',
|
|
286
|
+
path: context.path,
|
|
287
|
+
nodeType: node.type,
|
|
288
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
var appropriateInputType = {
|
|
295
|
+
id: "form-input-type",
|
|
296
|
+
category: "form",
|
|
297
|
+
severity: "warning",
|
|
298
|
+
name: "Use appropriate input type",
|
|
299
|
+
description: "Using the correct input type improves UX on mobile and enables browser validation",
|
|
300
|
+
appliesTo: ["Input"],
|
|
301
|
+
check: (node, context) => {
|
|
302
|
+
const inputType = "inputType" in node ? String(node.inputType || "text") : "text";
|
|
303
|
+
const label = "label" in node ? String(node.label || "").toLowerCase() : "";
|
|
304
|
+
const placeholder = "placeholder" in node ? String(node.placeholder || "").toLowerCase() : "";
|
|
305
|
+
const combined = label + " " + placeholder;
|
|
306
|
+
const suggestions = [
|
|
307
|
+
{ keywords: ["email", "e-mail"], type: "email" },
|
|
308
|
+
{ keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
|
|
309
|
+
{ keywords: ["url", "website", "link"], type: "url" },
|
|
310
|
+
{ keywords: ["password", "pwd"], type: "password" },
|
|
311
|
+
{ keywords: ["search", "find", "query"], type: "search" },
|
|
312
|
+
{ keywords: ["date", "birthday", "dob"], type: "date" },
|
|
313
|
+
{ keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
|
|
314
|
+
];
|
|
315
|
+
for (const suggestion of suggestions) {
|
|
316
|
+
if (suggestion.keywords.some((k) => combined.includes(k)) && inputType !== suggestion.type) {
|
|
317
|
+
return {
|
|
318
|
+
ruleId: "form-input-type",
|
|
319
|
+
category: "form",
|
|
320
|
+
severity: "warning",
|
|
321
|
+
message: `Input appears to be for ${suggestion.type} but uses type="${inputType}"`,
|
|
322
|
+
description: `Using inputType="${suggestion.type}" enables better mobile keyboards and browser validation`,
|
|
323
|
+
suggestion: `Change inputType to "${suggestion.type}"`,
|
|
324
|
+
path: context.path,
|
|
325
|
+
nodeType: node.type,
|
|
326
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
var formRules = [
|
|
334
|
+
formRequiresSubmit,
|
|
335
|
+
requiredFieldIndicator,
|
|
336
|
+
passwordConfirmation,
|
|
337
|
+
appropriateInputType
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
// src/rules/touch-target.ts
|
|
341
|
+
var MIN_TOUCH_TARGET = 44;
|
|
342
|
+
var SIZE_MAP = {
|
|
343
|
+
xs: 24,
|
|
344
|
+
sm: 32,
|
|
345
|
+
md: 40,
|
|
346
|
+
lg: 48,
|
|
347
|
+
xl: 56
|
|
348
|
+
};
|
|
349
|
+
function getSizeValue(node) {
|
|
350
|
+
if (!("size" in node)) return null;
|
|
351
|
+
const size = node.size;
|
|
352
|
+
if (typeof size === "number") return size;
|
|
353
|
+
if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
var buttonTouchTarget = {
|
|
357
|
+
id: "touch-button-size",
|
|
358
|
+
category: "touch-target",
|
|
359
|
+
severity: "warning",
|
|
360
|
+
name: "Button touch target size",
|
|
361
|
+
description: "Buttons should be at least 44x44px for comfortable touch interaction",
|
|
362
|
+
appliesTo: ["Button"],
|
|
363
|
+
check: (node, context) => {
|
|
364
|
+
const size = getSizeValue(node);
|
|
365
|
+
if (size !== null && size < MIN_TOUCH_TARGET) {
|
|
366
|
+
return {
|
|
367
|
+
ruleId: "touch-button-size",
|
|
368
|
+
category: "touch-target",
|
|
369
|
+
severity: "warning",
|
|
370
|
+
message: `Button size (${size}px) is below minimum touch target (${MIN_TOUCH_TARGET}px)`,
|
|
371
|
+
description: "Small touch targets are difficult to tap on mobile devices",
|
|
372
|
+
suggestion: `Use size="md" or larger for better touch accessibility`,
|
|
373
|
+
path: context.path,
|
|
374
|
+
nodeType: node.type,
|
|
375
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
var iconButtonTouchTarget = {
|
|
382
|
+
id: "touch-icon-button-size",
|
|
383
|
+
category: "touch-target",
|
|
384
|
+
severity: "warning",
|
|
385
|
+
name: "Icon button touch target size",
|
|
386
|
+
description: "Icon-only buttons should have adequate padding for touch",
|
|
387
|
+
appliesTo: ["Button"],
|
|
388
|
+
check: (node, context) => {
|
|
389
|
+
const hasIcon = "icon" in node && node.icon;
|
|
390
|
+
const hasContent = "content" in node && node.content && String(node.content).trim();
|
|
391
|
+
if (!hasIcon || hasContent) return null;
|
|
392
|
+
const size = getSizeValue(node);
|
|
393
|
+
const padding = "p" in node ? Number(node.p) * 4 : 0;
|
|
394
|
+
if (size !== null && size < MIN_TOUCH_TARGET && padding < 8) {
|
|
395
|
+
return {
|
|
396
|
+
ruleId: "touch-icon-button-size",
|
|
397
|
+
category: "touch-target",
|
|
398
|
+
severity: "warning",
|
|
399
|
+
message: "Icon-only button may have insufficient touch target",
|
|
400
|
+
description: "Icon buttons need adequate padding to meet touch target requirements",
|
|
401
|
+
suggestion: "Add padding (p=2 or more) or use a larger size",
|
|
402
|
+
path: context.path,
|
|
403
|
+
nodeType: node.type,
|
|
404
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
var checkboxRadioTouchTarget = {
|
|
411
|
+
id: "touch-checkbox-radio-size",
|
|
412
|
+
category: "touch-target",
|
|
413
|
+
severity: "info",
|
|
414
|
+
name: "Checkbox/Radio touch target",
|
|
415
|
+
description: "Checkboxes and radio buttons should be easy to tap",
|
|
416
|
+
appliesTo: ["Checkbox", "Radio"],
|
|
417
|
+
check: (node, context) => {
|
|
418
|
+
const hasLabel = "label" in node && node.label;
|
|
419
|
+
if (!hasLabel) {
|
|
420
|
+
return {
|
|
421
|
+
ruleId: "touch-checkbox-radio-size",
|
|
422
|
+
category: "touch-target",
|
|
423
|
+
severity: "info",
|
|
424
|
+
message: `${node.type} without label has small touch target`,
|
|
425
|
+
description: "The label extends the touch target area for checkboxes and radio buttons",
|
|
426
|
+
suggestion: "Add a label to increase the touch target area",
|
|
427
|
+
path: context.path,
|
|
428
|
+
nodeType: node.type,
|
|
429
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
var linkSpacing = {
|
|
436
|
+
id: "touch-link-spacing",
|
|
437
|
+
category: "touch-target",
|
|
438
|
+
severity: "info",
|
|
439
|
+
name: "Link spacing for touch",
|
|
440
|
+
description: "Links in close proximity can be difficult to tap accurately",
|
|
441
|
+
appliesTo: ["Link"],
|
|
442
|
+
check: (node, context) => {
|
|
443
|
+
const siblingLinks = context.siblings.filter((s) => s.type === "Link");
|
|
444
|
+
if (siblingLinks.length > 1) {
|
|
445
|
+
const parent = context.parent;
|
|
446
|
+
if (parent && parent.type === "Row") {
|
|
447
|
+
const gap = "gap" in parent ? Number(parent.gap) : 0;
|
|
448
|
+
if (gap < 2) {
|
|
449
|
+
return {
|
|
450
|
+
ruleId: "touch-link-spacing",
|
|
451
|
+
category: "touch-target",
|
|
452
|
+
severity: "info",
|
|
453
|
+
message: "Multiple links in row may be too close together",
|
|
454
|
+
description: "Adjacent tap targets should have adequate spacing",
|
|
455
|
+
suggestion: "Add gap=2 or more to the parent row for better touch separation",
|
|
456
|
+
path: context.path,
|
|
457
|
+
nodeType: node.type,
|
|
458
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
var avatarTouchTarget = {
|
|
467
|
+
id: "touch-avatar-size",
|
|
468
|
+
category: "touch-target",
|
|
469
|
+
severity: "info",
|
|
470
|
+
name: "Avatar touch target size",
|
|
471
|
+
description: "Clickable avatars should meet touch target requirements",
|
|
472
|
+
appliesTo: ["Avatar"],
|
|
473
|
+
check: (node, context) => {
|
|
474
|
+
const size = getSizeValue(node);
|
|
475
|
+
if (size !== null && size < MIN_TOUCH_TARGET) {
|
|
476
|
+
return {
|
|
477
|
+
ruleId: "touch-avatar-size",
|
|
478
|
+
category: "touch-target",
|
|
479
|
+
severity: "info",
|
|
480
|
+
message: `Avatar size (${size}px) may be too small if clickable`,
|
|
481
|
+
description: "If this avatar is interactive, it should meet minimum touch target size",
|
|
482
|
+
suggestion: 'Use size="md" or larger for clickable avatars',
|
|
483
|
+
path: context.path,
|
|
484
|
+
nodeType: node.type,
|
|
485
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
var touchTargetRules = [
|
|
492
|
+
buttonTouchTarget,
|
|
493
|
+
iconButtonTouchTarget,
|
|
494
|
+
checkboxRadioTouchTarget,
|
|
495
|
+
linkSpacing,
|
|
496
|
+
avatarTouchTarget
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
// src/rules/consistency.ts
|
|
500
|
+
var buttonStyleTracker = /* @__PURE__ */ new Map();
|
|
501
|
+
function resetConsistencyTrackers() {
|
|
502
|
+
buttonStyleTracker.clear();
|
|
503
|
+
}
|
|
504
|
+
function getButtonStyle(node) {
|
|
505
|
+
if ("primary" in node && node.primary) return "primary";
|
|
506
|
+
if ("secondary" in node && node.secondary) return "secondary";
|
|
507
|
+
if ("outline" in node && node.outline) return "outline";
|
|
508
|
+
if ("ghost" in node && node.ghost) return "ghost";
|
|
509
|
+
if ("danger" in node && node.danger) return "danger";
|
|
510
|
+
return "default";
|
|
511
|
+
}
|
|
512
|
+
var consistentButtonStyles = {
|
|
513
|
+
id: "consistency-button-styles",
|
|
514
|
+
category: "consistency",
|
|
515
|
+
severity: "info",
|
|
516
|
+
name: "Consistent button styles",
|
|
517
|
+
description: "Action buttons in the same context should use consistent styling",
|
|
518
|
+
appliesTo: ["Row", "Col", "Card", "Modal"],
|
|
519
|
+
check: (node, context) => {
|
|
520
|
+
if (!("children" in node) || !Array.isArray(node.children)) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
const buttons = node.children.filter((c) => c.type === "Button");
|
|
524
|
+
if (buttons.length < 2) return null;
|
|
525
|
+
const styles = buttons.map((b) => getButtonStyle(b));
|
|
526
|
+
const uniqueStyles = [...new Set(styles)];
|
|
527
|
+
const hasCommonPattern = uniqueStyles.length === 2 && uniqueStyles.includes("primary") && (uniqueStyles.includes("outline") || uniqueStyles.includes("ghost")) || uniqueStyles.length === 2 && uniqueStyles.includes("primary") && uniqueStyles.includes("secondary");
|
|
528
|
+
if (hasCommonPattern) return null;
|
|
529
|
+
if (uniqueStyles.length > 2) {
|
|
530
|
+
return {
|
|
531
|
+
ruleId: "consistency-button-styles",
|
|
532
|
+
category: "consistency",
|
|
533
|
+
severity: "info",
|
|
534
|
+
message: `Multiple button styles (${uniqueStyles.join(", ")}) in same container`,
|
|
535
|
+
description: "Using many different button styles can confuse users about action hierarchy",
|
|
536
|
+
suggestion: "Use primary for main action, outline/ghost for secondary actions",
|
|
537
|
+
path: context.path,
|
|
538
|
+
nodeType: node.type,
|
|
539
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
var consistentSpacing = {
|
|
546
|
+
id: "consistency-spacing",
|
|
547
|
+
category: "consistency",
|
|
548
|
+
severity: "info",
|
|
549
|
+
name: "Consistent spacing",
|
|
550
|
+
description: "Spacing should be consistent across similar elements",
|
|
551
|
+
appliesTo: ["Row", "Col"],
|
|
552
|
+
check: (node, context) => {
|
|
553
|
+
const siblingRows = context.siblings.filter((s) => s.type === node.type);
|
|
554
|
+
if (siblingRows.length < 2) return null;
|
|
555
|
+
const gaps = siblingRows.map((r) => "gap" in r ? Number(r.gap) : null).filter((g) => g !== null);
|
|
556
|
+
const uniqueGaps = [...new Set(gaps)];
|
|
557
|
+
if (uniqueGaps.length > 2) {
|
|
558
|
+
return {
|
|
559
|
+
ruleId: "consistency-spacing",
|
|
560
|
+
category: "consistency",
|
|
561
|
+
severity: "info",
|
|
562
|
+
message: "Sibling containers have inconsistent gap values",
|
|
563
|
+
description: "Consistent spacing creates visual harmony and rhythm",
|
|
564
|
+
suggestion: "Use the same gap value for sibling containers",
|
|
565
|
+
path: context.path,
|
|
566
|
+
nodeType: node.type,
|
|
567
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
var consistentCardStyling = {
|
|
574
|
+
id: "consistency-card-styling",
|
|
575
|
+
category: "consistency",
|
|
576
|
+
severity: "info",
|
|
577
|
+
name: "Consistent card styling",
|
|
578
|
+
description: "Cards in the same context should have consistent visual treatment",
|
|
579
|
+
appliesTo: ["Card"],
|
|
580
|
+
check: (node, context) => {
|
|
581
|
+
const siblingCards = context.siblings.filter((s) => s.type === "Card");
|
|
582
|
+
if (siblingCards.length < 2) return null;
|
|
583
|
+
const hasShadow = "shadow" in node && node.shadow;
|
|
584
|
+
const hasBorder = "border" in node && node.border;
|
|
585
|
+
const padding = "p" in node ? Number(node.p) : null;
|
|
586
|
+
let inconsistencies = [];
|
|
587
|
+
for (const sibling of siblingCards) {
|
|
588
|
+
if (sibling === node) continue;
|
|
589
|
+
const sibShadow = "shadow" in sibling && sibling.shadow;
|
|
590
|
+
const sibBorder = "border" in sibling && sibling.border;
|
|
591
|
+
const sibPadding = "p" in sibling ? Number(sibling.p) : null;
|
|
592
|
+
if (!!hasShadow !== !!sibShadow) inconsistencies.push("shadow");
|
|
593
|
+
if (!!hasBorder !== !!sibBorder) inconsistencies.push("border");
|
|
594
|
+
if (padding !== sibPadding) inconsistencies.push("padding");
|
|
595
|
+
}
|
|
596
|
+
if (inconsistencies.length > 0) {
|
|
597
|
+
const uniqueIssues = [...new Set(inconsistencies)];
|
|
598
|
+
return {
|
|
599
|
+
ruleId: "consistency-card-styling",
|
|
600
|
+
category: "consistency",
|
|
601
|
+
severity: "info",
|
|
602
|
+
message: `Cards have inconsistent ${uniqueIssues.join(", ")}`,
|
|
603
|
+
description: "Consistent card styling helps users understand that cards are related",
|
|
604
|
+
suggestion: "Apply the same visual treatment to sibling cards",
|
|
605
|
+
path: context.path,
|
|
606
|
+
nodeType: node.type,
|
|
607
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var consistentAlertVariants = {
|
|
614
|
+
id: "consistency-alert-variants",
|
|
615
|
+
category: "consistency",
|
|
616
|
+
severity: "warning",
|
|
617
|
+
name: "Consistent alert variants",
|
|
618
|
+
description: "Alerts should use appropriate variants for their purpose",
|
|
619
|
+
appliesTo: ["Alert"],
|
|
620
|
+
check: (node, context) => {
|
|
621
|
+
const content = "content" in node ? String(node.content || "").toLowerCase() : "";
|
|
622
|
+
const variant = "variant" in node ? String(node.variant || "") : "";
|
|
623
|
+
const errorWords = ["error", "fail", "invalid", "wrong", "denied"];
|
|
624
|
+
const successWords = ["success", "saved", "created", "updated", "complete"];
|
|
625
|
+
const warningWords = ["warning", "caution", "attention", "note"];
|
|
626
|
+
if (errorWords.some((w) => content.includes(w)) && variant !== "danger") {
|
|
627
|
+
return {
|
|
628
|
+
ruleId: "consistency-alert-variants",
|
|
629
|
+
category: "consistency",
|
|
630
|
+
severity: "warning",
|
|
631
|
+
message: "Error message should use danger variant",
|
|
632
|
+
description: "Users expect error messages to be visually distinct (usually red)",
|
|
633
|
+
suggestion: "Add variant=danger to this error alert",
|
|
634
|
+
path: context.path,
|
|
635
|
+
nodeType: node.type,
|
|
636
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (successWords.some((w) => content.includes(w)) && variant !== "success") {
|
|
640
|
+
return {
|
|
641
|
+
ruleId: "consistency-alert-variants",
|
|
642
|
+
category: "consistency",
|
|
643
|
+
severity: "warning",
|
|
644
|
+
message: "Success message should use success variant",
|
|
645
|
+
description: "Users expect success messages to be visually distinct (usually green)",
|
|
646
|
+
suggestion: "Add variant=success to this success alert",
|
|
647
|
+
path: context.path,
|
|
648
|
+
nodeType: node.type,
|
|
649
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
if (warningWords.some((w) => content.includes(w)) && variant !== "warning") {
|
|
653
|
+
return {
|
|
654
|
+
ruleId: "consistency-alert-variants",
|
|
655
|
+
category: "consistency",
|
|
656
|
+
severity: "info",
|
|
657
|
+
message: "Warning message should use warning variant",
|
|
658
|
+
description: "Users expect warning messages to be visually distinct (usually yellow/orange)",
|
|
659
|
+
suggestion: "Add variant=warning to this warning alert",
|
|
660
|
+
path: context.path,
|
|
661
|
+
nodeType: node.type,
|
|
662
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
var consistencyRules = [
|
|
669
|
+
consistentButtonStyles,
|
|
670
|
+
consistentSpacing,
|
|
671
|
+
consistentCardStyling,
|
|
672
|
+
consistentAlertVariants
|
|
673
|
+
];
|
|
674
|
+
|
|
675
|
+
// src/rules/usability.ts
|
|
676
|
+
var noEmptyContainers = {
|
|
677
|
+
id: "usability-empty-container",
|
|
678
|
+
category: "usability",
|
|
679
|
+
severity: "warning",
|
|
680
|
+
name: "Avoid empty containers",
|
|
681
|
+
description: "Containers without content may confuse users or indicate missing content",
|
|
682
|
+
appliesTo: ["Card", "Section", "Modal", "Drawer"],
|
|
683
|
+
check: (node, context) => {
|
|
684
|
+
if (!("children" in node) || !Array.isArray(node.children) || node.children.length === 0) {
|
|
685
|
+
return {
|
|
686
|
+
ruleId: "usability-empty-container",
|
|
687
|
+
category: "usability",
|
|
688
|
+
severity: "warning",
|
|
689
|
+
message: `${node.type} has no content`,
|
|
690
|
+
description: "Empty containers may represent incomplete design or confuse users",
|
|
691
|
+
suggestion: "Add content to this container or use a placeholder to indicate intended content",
|
|
692
|
+
path: context.path,
|
|
693
|
+
nodeType: node.type,
|
|
694
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
var clearCTA = {
|
|
701
|
+
id: "usability-clear-cta",
|
|
702
|
+
category: "usability",
|
|
703
|
+
severity: "info",
|
|
704
|
+
name: "Clear call to action",
|
|
705
|
+
description: "Pages should have a clear primary action for users",
|
|
706
|
+
appliesTo: ["Page"],
|
|
707
|
+
check: (node, context) => {
|
|
708
|
+
if (!("children" in node) || !Array.isArray(node.children)) {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
let hasPrimaryButton = false;
|
|
712
|
+
function findPrimaryButton(children) {
|
|
713
|
+
for (const child of children) {
|
|
714
|
+
if (child.type === "Button" && "primary" in child && child.primary) {
|
|
715
|
+
hasPrimaryButton = true;
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if ("children" in child && Array.isArray(child.children)) {
|
|
719
|
+
findPrimaryButton(child.children);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
findPrimaryButton(node.children);
|
|
724
|
+
if (!hasPrimaryButton) {
|
|
725
|
+
return {
|
|
726
|
+
ruleId: "usability-clear-cta",
|
|
727
|
+
category: "usability",
|
|
728
|
+
severity: "info",
|
|
729
|
+
message: "Page has no primary button (CTA)",
|
|
730
|
+
description: "A clear call-to-action helps guide users to the main action",
|
|
731
|
+
suggestion: "Add a primary button for the main action on this page",
|
|
732
|
+
path: context.path,
|
|
733
|
+
nodeType: node.type,
|
|
734
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
var loadingStates = {
|
|
741
|
+
id: "usability-loading-states",
|
|
742
|
+
category: "usability",
|
|
743
|
+
severity: "info",
|
|
744
|
+
name: "Consider loading states",
|
|
745
|
+
description: "Actions that may take time should have loading indicators",
|
|
746
|
+
appliesTo: ["Button"],
|
|
747
|
+
check: (node, context) => {
|
|
748
|
+
const content = "content" in node ? String(node.content || "").toLowerCase() : "";
|
|
749
|
+
const hasLoading = "loading" in node;
|
|
750
|
+
const isPrimary = "primary" in node && node.primary;
|
|
751
|
+
const asyncActions = ["submit", "save", "send", "upload", "download", "export", "import", "sync", "load"];
|
|
752
|
+
if (isPrimary && asyncActions.some((a) => content.includes(a)) && !hasLoading) {
|
|
753
|
+
return {
|
|
754
|
+
ruleId: "usability-loading-states",
|
|
755
|
+
category: "usability",
|
|
756
|
+
severity: "info",
|
|
757
|
+
message: `Button "${content}" may need a loading state`,
|
|
758
|
+
description: "Async actions should show progress to prevent double-clicks and inform users",
|
|
759
|
+
suggestion: "Consider adding a loading variant for this button when action is in progress",
|
|
760
|
+
path: context.path,
|
|
761
|
+
nodeType: node.type,
|
|
762
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
var destructiveActionConfirmation = {
|
|
769
|
+
id: "usability-destructive-confirm",
|
|
770
|
+
category: "usability",
|
|
771
|
+
severity: "warning",
|
|
772
|
+
name: "Destructive actions need confirmation",
|
|
773
|
+
description: "Destructive actions should have clear warning styling",
|
|
774
|
+
appliesTo: ["Button"],
|
|
775
|
+
check: (node, context) => {
|
|
776
|
+
const content = "content" in node ? String(node.content || "").toLowerCase() : "";
|
|
777
|
+
const isDanger = "danger" in node && node.danger;
|
|
778
|
+
const destructiveWords = ["delete", "remove", "destroy", "clear", "reset", "revoke", "terminate"];
|
|
779
|
+
if (destructiveWords.some((w) => content.includes(w)) && !isDanger) {
|
|
780
|
+
return {
|
|
781
|
+
ruleId: "usability-destructive-confirm",
|
|
782
|
+
category: "usability",
|
|
783
|
+
severity: "warning",
|
|
784
|
+
message: `Destructive action "${content}" should use danger styling`,
|
|
785
|
+
description: "Users should be visually warned about destructive actions",
|
|
786
|
+
suggestion: "Add the danger attribute to this button",
|
|
787
|
+
path: context.path,
|
|
788
|
+
nodeType: node.type,
|
|
789
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
var modalCloseButton = {
|
|
796
|
+
id: "usability-modal-close",
|
|
797
|
+
category: "usability",
|
|
798
|
+
severity: "warning",
|
|
799
|
+
name: "Modal should have close mechanism",
|
|
800
|
+
description: "Users should be able to close modals easily",
|
|
801
|
+
appliesTo: ["Modal"],
|
|
802
|
+
check: (node, context) => {
|
|
803
|
+
if (!("children" in node) || !Array.isArray(node.children)) {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
let hasCloseButton = false;
|
|
807
|
+
function findCloseButton(children) {
|
|
808
|
+
for (const child of children) {
|
|
809
|
+
if (child.type === "Button") {
|
|
810
|
+
const content = "content" in child ? String(child.content || "").toLowerCase() : "";
|
|
811
|
+
const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
|
|
812
|
+
if (["close", "cancel", "dismiss", "x"].some((w) => content.includes(w) || icon.includes(w))) {
|
|
813
|
+
hasCloseButton = true;
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (child.type === "Icon") {
|
|
818
|
+
const name = "name" in child ? String(child.name || "").toLowerCase() : "";
|
|
819
|
+
if (name === "x" || name === "close") {
|
|
820
|
+
hasCloseButton = true;
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if ("children" in child && Array.isArray(child.children)) {
|
|
825
|
+
findCloseButton(child.children);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
findCloseButton(node.children);
|
|
830
|
+
if (!hasCloseButton) {
|
|
831
|
+
return {
|
|
832
|
+
ruleId: "usability-modal-close",
|
|
833
|
+
category: "usability",
|
|
834
|
+
severity: "warning",
|
|
835
|
+
message: "Modal has no visible close button",
|
|
836
|
+
description: "Users should have a clear way to dismiss the modal",
|
|
837
|
+
suggestion: 'Add a close button (icon "x") or a "Cancel" button',
|
|
838
|
+
path: context.path,
|
|
839
|
+
nodeType: node.type,
|
|
840
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
var maxNestingDepth = {
|
|
847
|
+
id: "usability-nesting-depth",
|
|
848
|
+
category: "usability",
|
|
849
|
+
severity: "warning",
|
|
850
|
+
name: "Avoid excessive nesting",
|
|
851
|
+
description: "Deeply nested layouts can be confusing and hard to maintain",
|
|
852
|
+
appliesTo: ["Row", "Col", "Card", "Section"],
|
|
853
|
+
check: (node, context) => {
|
|
854
|
+
const MAX_DEPTH = 6;
|
|
855
|
+
if (context.depth > MAX_DEPTH) {
|
|
856
|
+
return {
|
|
857
|
+
ruleId: "usability-nesting-depth",
|
|
858
|
+
category: "usability",
|
|
859
|
+
severity: "warning",
|
|
860
|
+
message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_DEPTH})`,
|
|
861
|
+
description: "Excessive nesting makes layouts harder to understand and maintain",
|
|
862
|
+
suggestion: "Consider flattening the layout or breaking into separate sections",
|
|
863
|
+
path: context.path,
|
|
864
|
+
nodeType: node.type,
|
|
865
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
var usabilityRules = [
|
|
872
|
+
noEmptyContainers,
|
|
873
|
+
clearCTA,
|
|
874
|
+
loadingStates,
|
|
875
|
+
destructiveActionConfirmation,
|
|
876
|
+
modalCloseButton,
|
|
877
|
+
maxNestingDepth
|
|
878
|
+
];
|
|
879
|
+
|
|
880
|
+
// src/rules/navigation.ts
|
|
881
|
+
var navItemCount = {
|
|
882
|
+
id: "nav-item-count",
|
|
883
|
+
category: "navigation",
|
|
884
|
+
severity: "warning",
|
|
885
|
+
name: "Navigation item count",
|
|
886
|
+
description: "Navigation menus with too many items can overwhelm users",
|
|
887
|
+
appliesTo: ["Nav"],
|
|
888
|
+
check: (node, context) => {
|
|
889
|
+
const MAX_NAV_ITEMS = 7;
|
|
890
|
+
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
891
|
+
if (items.length > MAX_NAV_ITEMS) {
|
|
892
|
+
return {
|
|
893
|
+
ruleId: "nav-item-count",
|
|
894
|
+
category: "navigation",
|
|
895
|
+
severity: "warning",
|
|
896
|
+
message: `Navigation has ${items.length} items (recommended max: ${MAX_NAV_ITEMS})`,
|
|
897
|
+
description: "Too many navigation items can overwhelm users and slow decision-making",
|
|
898
|
+
suggestion: "Group related items into categories or use a hierarchical navigation",
|
|
899
|
+
path: context.path,
|
|
900
|
+
nodeType: node.type,
|
|
901
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
var navActiveState = {
|
|
908
|
+
id: "nav-active-state",
|
|
909
|
+
category: "navigation",
|
|
910
|
+
severity: "info",
|
|
911
|
+
name: "Navigation should show active state",
|
|
912
|
+
description: "Users should know which page they are currently on",
|
|
913
|
+
appliesTo: ["Nav"],
|
|
914
|
+
check: (node, context) => {
|
|
915
|
+
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
916
|
+
if (items.length === 0) return null;
|
|
917
|
+
const hasActiveItem = items.some((item) => {
|
|
918
|
+
if (typeof item === "object" && item !== null) {
|
|
919
|
+
return "active" in item && item.active;
|
|
920
|
+
}
|
|
921
|
+
return false;
|
|
922
|
+
});
|
|
923
|
+
if (!hasActiveItem) {
|
|
924
|
+
return {
|
|
925
|
+
ruleId: "nav-active-state",
|
|
926
|
+
category: "navigation",
|
|
927
|
+
severity: "info",
|
|
928
|
+
message: "Navigation has no active state indicated",
|
|
929
|
+
description: "Users should be able to see which page/section they are currently viewing",
|
|
930
|
+
suggestion: "Add active attribute to the current navigation item",
|
|
931
|
+
path: context.path,
|
|
932
|
+
nodeType: node.type,
|
|
933
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
var breadcrumbHasHome = {
|
|
940
|
+
id: "nav-breadcrumb-home",
|
|
941
|
+
category: "navigation",
|
|
942
|
+
severity: "info",
|
|
943
|
+
name: "Breadcrumb should start with home",
|
|
944
|
+
description: "Breadcrumbs typically start with a home or root link",
|
|
945
|
+
appliesTo: ["Breadcrumb"],
|
|
946
|
+
check: (node, context) => {
|
|
947
|
+
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
948
|
+
if (items.length === 0) return null;
|
|
949
|
+
const firstItem = items[0];
|
|
950
|
+
const firstLabel = typeof firstItem === "string" ? firstItem.toLowerCase() : firstItem?.label?.toLowerCase() || "";
|
|
951
|
+
const homeWords = ["home", "dashboard", "main", "start"];
|
|
952
|
+
if (!homeWords.some((w) => firstLabel.includes(w))) {
|
|
953
|
+
return {
|
|
954
|
+
ruleId: "nav-breadcrumb-home",
|
|
955
|
+
category: "navigation",
|
|
956
|
+
severity: "info",
|
|
957
|
+
message: "Breadcrumb does not start with a home/root link",
|
|
958
|
+
description: "Users expect breadcrumbs to start from a known root location",
|
|
959
|
+
suggestion: 'Add "Home" or equivalent as the first breadcrumb item',
|
|
960
|
+
path: context.path,
|
|
961
|
+
nodeType: node.type,
|
|
962
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
var tabCount = {
|
|
969
|
+
id: "nav-tab-count",
|
|
970
|
+
category: "navigation",
|
|
971
|
+
severity: "warning",
|
|
972
|
+
name: "Tab count",
|
|
973
|
+
description: "Too many tabs can be overwhelming",
|
|
974
|
+
appliesTo: ["Tabs"],
|
|
975
|
+
check: (node, context) => {
|
|
976
|
+
const MAX_TABS = 5;
|
|
977
|
+
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
978
|
+
const children = "children" in node && Array.isArray(node.children) ? node.children : [];
|
|
979
|
+
const childTabCount = children.filter((c) => {
|
|
980
|
+
if (typeof c === "object" && c !== null && "type" in c) {
|
|
981
|
+
return String(c.type).toLowerCase().includes("tab");
|
|
982
|
+
}
|
|
983
|
+
return false;
|
|
984
|
+
}).length;
|
|
985
|
+
const tabCount2 = items.length || childTabCount;
|
|
986
|
+
if (tabCount2 > MAX_TABS) {
|
|
987
|
+
return {
|
|
988
|
+
ruleId: "nav-tab-count",
|
|
989
|
+
category: "navigation",
|
|
990
|
+
severity: "warning",
|
|
991
|
+
message: `Tabs has ${tabCount2} items (recommended max: ${MAX_TABS})`,
|
|
992
|
+
description: "Too many tabs can overwhelm users and may not fit on smaller screens",
|
|
993
|
+
suggestion: "Consider using a different navigation pattern or grouping related content",
|
|
994
|
+
path: context.path,
|
|
995
|
+
nodeType: node.type,
|
|
996
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
};
|
|
1002
|
+
var dropdownHasItems = {
|
|
1003
|
+
id: "nav-dropdown-items",
|
|
1004
|
+
category: "navigation",
|
|
1005
|
+
severity: "warning",
|
|
1006
|
+
name: "Dropdown should have items",
|
|
1007
|
+
description: "Dropdown menus need items to be functional",
|
|
1008
|
+
appliesTo: ["Dropdown"],
|
|
1009
|
+
check: (node, context) => {
|
|
1010
|
+
const items = "items" in node && Array.isArray(node.items) ? node.items : [];
|
|
1011
|
+
const children = "children" in node && Array.isArray(node.children) ? node.children : [];
|
|
1012
|
+
if (items.length === 0 && children.length === 0) {
|
|
1013
|
+
return {
|
|
1014
|
+
ruleId: "nav-dropdown-items",
|
|
1015
|
+
category: "navigation",
|
|
1016
|
+
severity: "warning",
|
|
1017
|
+
message: "Dropdown has no items",
|
|
1018
|
+
description: "An empty dropdown provides no value to users",
|
|
1019
|
+
suggestion: "Add items to the dropdown menu",
|
|
1020
|
+
path: context.path,
|
|
1021
|
+
nodeType: node.type,
|
|
1022
|
+
location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
};
|
|
1028
|
+
var navigationRules = [
|
|
1029
|
+
navItemCount,
|
|
1030
|
+
navActiveState,
|
|
1031
|
+
breadcrumbHasHome,
|
|
1032
|
+
tabCount,
|
|
1033
|
+
dropdownHasItems
|
|
1034
|
+
];
|
|
1035
|
+
|
|
1036
|
+
// src/rules/index.ts
|
|
1037
|
+
var allRules = [
|
|
1038
|
+
...accessibilityRules,
|
|
1039
|
+
...formRules,
|
|
1040
|
+
...touchTargetRules,
|
|
1041
|
+
...consistencyRules,
|
|
1042
|
+
...usabilityRules,
|
|
1043
|
+
...navigationRules
|
|
1044
|
+
];
|
|
1045
|
+
var rulesByCategory = {
|
|
1046
|
+
accessibility: accessibilityRules,
|
|
1047
|
+
form: formRules,
|
|
1048
|
+
"touch-target": touchTargetRules,
|
|
1049
|
+
consistency: consistencyRules,
|
|
1050
|
+
usability: usabilityRules,
|
|
1051
|
+
navigation: navigationRules
|
|
1052
|
+
};
|
|
1053
|
+
function getRulesForCategories(categories) {
|
|
1054
|
+
if (categories.length === 0) return allRules;
|
|
1055
|
+
return categories.flatMap((cat) => {
|
|
1056
|
+
const rules = rulesByCategory[cat];
|
|
1057
|
+
return rules || [];
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/index.ts
|
|
1062
|
+
var SEVERITY_ORDER = {
|
|
1063
|
+
error: 0,
|
|
1064
|
+
warning: 1,
|
|
1065
|
+
info: 2
|
|
1066
|
+
};
|
|
1067
|
+
function validateUX(ast, options = {}) {
|
|
1068
|
+
const {
|
|
1069
|
+
categories = [],
|
|
1070
|
+
minSeverity = "info",
|
|
1071
|
+
maxIssues = 100,
|
|
1072
|
+
customRules = [],
|
|
1073
|
+
disabledRules = []
|
|
1074
|
+
} = options;
|
|
1075
|
+
resetConsistencyTrackers();
|
|
1076
|
+
let rules = categories.length > 0 ? getRulesForCategories(categories) : allRules;
|
|
1077
|
+
rules = [...rules, ...customRules];
|
|
1078
|
+
if (disabledRules.length > 0) {
|
|
1079
|
+
const disabledSet = new Set(disabledRules);
|
|
1080
|
+
rules = rules.filter((r) => !disabledSet.has(r.id));
|
|
1081
|
+
}
|
|
1082
|
+
const issues = [];
|
|
1083
|
+
const minSeverityOrder = SEVERITY_ORDER[minSeverity];
|
|
1084
|
+
const categoryStats = /* @__PURE__ */ new Map();
|
|
1085
|
+
function initCategoryStats(category) {
|
|
1086
|
+
if (!categoryStats.has(category)) {
|
|
1087
|
+
categoryStats.set(category, { passed: 0, failed: 0, warnings: 0 });
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function recordCheck(rule, issue) {
|
|
1091
|
+
initCategoryStats(rule.category);
|
|
1092
|
+
const stats = categoryStats.get(rule.category);
|
|
1093
|
+
if (issue === null) {
|
|
1094
|
+
stats.passed++;
|
|
1095
|
+
} else if (issue.severity === "error") {
|
|
1096
|
+
stats.failed++;
|
|
1097
|
+
} else {
|
|
1098
|
+
stats.warnings++;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function addIssue(issue) {
|
|
1102
|
+
if (SEVERITY_ORDER[issue.severity] > minSeverityOrder) {
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
if (issues.length >= maxIssues) {
|
|
1106
|
+
return false;
|
|
1107
|
+
}
|
|
1108
|
+
issues.push(issue);
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
function walkNode(node, path, parent, siblings, index, depth) {
|
|
1112
|
+
const context = {
|
|
1113
|
+
path,
|
|
1114
|
+
parent,
|
|
1115
|
+
root: ast.children[0],
|
|
1116
|
+
siblings,
|
|
1117
|
+
index,
|
|
1118
|
+
depth
|
|
1119
|
+
};
|
|
1120
|
+
for (const rule of rules) {
|
|
1121
|
+
if (rule.appliesTo.length > 0 && !rule.appliesTo.includes(node.type)) {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
try {
|
|
1125
|
+
const result = rule.check(node, context);
|
|
1126
|
+
if (result === null) {
|
|
1127
|
+
recordCheck(rule, null);
|
|
1128
|
+
} else if (Array.isArray(result)) {
|
|
1129
|
+
for (const issue of result) {
|
|
1130
|
+
recordCheck(rule, issue);
|
|
1131
|
+
if (!addIssue(issue)) return false;
|
|
1132
|
+
}
|
|
1133
|
+
} else {
|
|
1134
|
+
recordCheck(rule, result);
|
|
1135
|
+
if (!addIssue(result)) return false;
|
|
1136
|
+
}
|
|
1137
|
+
} catch {
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
1141
|
+
const children = node.children;
|
|
1142
|
+
for (let i = 0; i < children.length; i++) {
|
|
1143
|
+
const child = children[i];
|
|
1144
|
+
if (child && typeof child === "object" && "type" in child) {
|
|
1145
|
+
const shouldContinue = walkNode(
|
|
1146
|
+
child,
|
|
1147
|
+
`${path}.children[${i}]`,
|
|
1148
|
+
node,
|
|
1149
|
+
children,
|
|
1150
|
+
i,
|
|
1151
|
+
depth + 1
|
|
1152
|
+
);
|
|
1153
|
+
if (!shouldContinue) return false;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return true;
|
|
1158
|
+
}
|
|
1159
|
+
if (ast.children) {
|
|
1160
|
+
for (let i = 0; i < ast.children.length; i++) {
|
|
1161
|
+
const page = ast.children[i];
|
|
1162
|
+
const children = ast.children;
|
|
1163
|
+
walkNode(page, `pages[${i}]`, null, children, i, 0);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
1167
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
1168
|
+
const infoCount = issues.filter((i) => i.severity === "info").length;
|
|
1169
|
+
const errorPenalty = errorCount * 10;
|
|
1170
|
+
const warningPenalty = warningCount * 3;
|
|
1171
|
+
const infoPenalty = infoCount * 1;
|
|
1172
|
+
const totalPenalty = errorPenalty + warningPenalty + infoPenalty;
|
|
1173
|
+
const score = Math.max(0, Math.min(100, 100 - totalPenalty));
|
|
1174
|
+
const summary = Array.from(categoryStats.entries()).map(([category, stats]) => ({
|
|
1175
|
+
category,
|
|
1176
|
+
passed: stats.passed,
|
|
1177
|
+
failed: stats.failed,
|
|
1178
|
+
warnings: stats.warnings
|
|
1179
|
+
}));
|
|
1180
|
+
return {
|
|
1181
|
+
valid: errorCount === 0,
|
|
1182
|
+
score,
|
|
1183
|
+
issues,
|
|
1184
|
+
summary,
|
|
1185
|
+
severityCounts: {
|
|
1186
|
+
errors: errorCount,
|
|
1187
|
+
warnings: warningCount,
|
|
1188
|
+
info: infoCount
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
function isUXValid(ast) {
|
|
1193
|
+
const result = validateUX(ast, { minSeverity: "error", maxIssues: 1 });
|
|
1194
|
+
return result.valid;
|
|
1195
|
+
}
|
|
1196
|
+
function getUXIssues(ast, options) {
|
|
1197
|
+
return validateUX(ast, options).issues;
|
|
1198
|
+
}
|
|
1199
|
+
function getUXScore(ast) {
|
|
1200
|
+
return validateUX(ast).score;
|
|
1201
|
+
}
|
|
1202
|
+
function formatUXResult(result) {
|
|
1203
|
+
const lines = [];
|
|
1204
|
+
lines.push(`UX Validation Score: ${result.score}/100`);
|
|
1205
|
+
lines.push(`Status: ${result.valid ? "PASSED" : "FAILED"}`);
|
|
1206
|
+
lines.push("");
|
|
1207
|
+
if (result.issues.length > 0) {
|
|
1208
|
+
lines.push("Issues:");
|
|
1209
|
+
lines.push("");
|
|
1210
|
+
for (const issue of result.issues) {
|
|
1211
|
+
const icon = issue.severity === "error" ? "\u274C" : issue.severity === "warning" ? "\u26A0\uFE0F" : "\u2139\uFE0F";
|
|
1212
|
+
lines.push(`${icon} [${issue.ruleId}] ${issue.message}`);
|
|
1213
|
+
lines.push(` Path: ${issue.path}`);
|
|
1214
|
+
lines.push(` ${issue.suggestion}`);
|
|
1215
|
+
lines.push("");
|
|
1216
|
+
}
|
|
1217
|
+
} else {
|
|
1218
|
+
lines.push("No issues found.");
|
|
1219
|
+
}
|
|
1220
|
+
lines.push("Summary by category:");
|
|
1221
|
+
for (const cat of result.summary) {
|
|
1222
|
+
lines.push(` ${cat.category}: ${cat.passed} passed, ${cat.failed} errors, ${cat.warnings} warnings`);
|
|
1223
|
+
}
|
|
1224
|
+
return lines.join("\n");
|
|
1225
|
+
}
|
|
1226
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1227
|
+
0 && (module.exports = {
|
|
1228
|
+
allRules,
|
|
1229
|
+
formatUXResult,
|
|
1230
|
+
getRulesForCategories,
|
|
1231
|
+
getUXIssues,
|
|
1232
|
+
getUXScore,
|
|
1233
|
+
isUXValid,
|
|
1234
|
+
rulesByCategory,
|
|
1235
|
+
validateUX
|
|
1236
|
+
});
|