@wireweave/ux-rules 1.0.0 → 1.1.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/dist/index.cjs CHANGED
@@ -20,17 +20,302 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ ASYNC_ACTION_WORDS: () => ASYNC_ACTION_WORDS,
24
+ CLOSE_WORDS: () => CLOSE_WORDS,
25
+ CONTAINER_TYPES: () => CONTAINER_TYPES,
26
+ DESTRUCTIVE_WORDS: () => DESTRUCTIVE_WORDS,
27
+ ERROR_WORDS: () => ERROR_WORDS,
28
+ FORM_INPUT_TYPES: () => FORM_INPUT_TYPES,
29
+ GENERIC_LINK_TEXTS: () => GENERIC_LINK_TEXTS,
30
+ HOME_WORDS: () => HOME_WORDS,
31
+ INPUT_TYPE_SUGGESTIONS: () => INPUT_TYPE_SUGGESTIONS,
32
+ MAX_BUTTONS: () => MAX_BUTTONS,
33
+ MAX_BUTTON_TEXT_LENGTH: () => MAX_BUTTON_TEXT_LENGTH,
34
+ MAX_FORM_FIELDS: () => MAX_FORM_FIELDS,
35
+ MAX_LIST_ITEMS: () => MAX_LIST_ITEMS,
36
+ MAX_NAV_ITEMS: () => MAX_NAV_ITEMS,
37
+ MAX_NESTING_DEPTH: () => MAX_NESTING_DEPTH,
38
+ MAX_PAGE_ELEMENTS: () => MAX_PAGE_ELEMENTS,
39
+ MAX_TABLE_COLUMNS: () => MAX_TABLE_COLUMNS,
40
+ MAX_TABS: () => MAX_TABS,
41
+ MAX_TITLE_LENGTH: () => MAX_TITLE_LENGTH,
42
+ MAX_TOAST_DURATION: () => MAX_TOAST_DURATION,
43
+ MAX_TOOLTIP_LENGTH: () => MAX_TOOLTIP_LENGTH,
44
+ MIN_TOAST_DURATION: () => MIN_TOAST_DURATION,
45
+ MIN_TOUCH_TARGET: () => MIN_TOUCH_TARGET,
46
+ PLACEHOLDER_PATTERNS: () => PLACEHOLDER_PATTERNS,
47
+ RECOMMENDED_TOUCH_TARGET: () => RECOMMENDED_TOUCH_TARGET,
48
+ SIZE_MAP: () => SIZE_MAP,
49
+ SUBMIT_WORDS: () => SUBMIT_WORDS,
50
+ SUCCESS_WORDS: () => SUCCESS_WORDS,
51
+ TEXT_CONTENT_TYPES: () => TEXT_CONTENT_TYPES,
52
+ WARNING_WORDS: () => WARNING_WORDS,
23
53
  allRules: () => allRules,
54
+ containsAnyWord: () => containsAnyWord,
55
+ countInChildren: () => countInChildren,
56
+ filterChildren: () => filterChildren,
57
+ findInChildren: () => findInChildren,
24
58
  formatUXResult: () => formatUXResult,
59
+ getButtonStyle: () => getButtonStyle,
60
+ getChildren: () => getChildren,
61
+ getNodeItems: () => getNodeItems,
62
+ getNodeLocation: () => getNodeLocation,
63
+ getNodeText: () => getNodeText,
64
+ getNodeTextLower: () => getNodeTextLower,
25
65
  getRulesForCategories: () => getRulesForCategories,
66
+ getSizeValue: () => getSizeValue,
26
67
  getUXIssues: () => getUXIssues,
27
68
  getUXScore: () => getUXScore,
69
+ hasChildMatching: () => hasChildMatching,
70
+ hasChildren: () => hasChildren,
71
+ isAnyNode: () => isAnyNode,
72
+ isNodeType: () => isNodeType,
28
73
  isUXValid: () => isUXValid,
74
+ matchesAnyWord: () => matchesAnyWord,
29
75
  rulesByCategory: () => rulesByCategory,
76
+ toAnyNodeArray: () => toAnyNodeArray,
30
77
  validateUX: () => validateUX
31
78
  });
32
79
  module.exports = __toCommonJS(src_exports);
33
80
 
81
+ // src/constants.ts
82
+ var MAX_NAV_ITEMS = 7;
83
+ var MAX_TABS = 5;
84
+ var MAX_NESTING_DEPTH = 6;
85
+ var MAX_BUTTONS = 5;
86
+ var MAX_FORM_FIELDS = 10;
87
+ var MAX_PAGE_ELEMENTS = 50;
88
+ var MAX_BUTTON_TEXT_LENGTH = 25;
89
+ var MAX_TITLE_LENGTH = 60;
90
+ var MAX_TOOLTIP_LENGTH = 100;
91
+ var MAX_LIST_ITEMS = 20;
92
+ var MAX_TABLE_COLUMNS = 8;
93
+ var MIN_TOUCH_TARGET = 44;
94
+ var RECOMMENDED_TOUCH_TARGET = 48;
95
+ var SIZE_MAP = {
96
+ xs: 24,
97
+ sm: 32,
98
+ md: 40,
99
+ lg: 48,
100
+ xl: 56
101
+ };
102
+ var MIN_TOAST_DURATION = 2e3;
103
+ var MAX_TOAST_DURATION = 1e4;
104
+ var GENERIC_LINK_TEXTS = [
105
+ "click here",
106
+ "here",
107
+ "read more",
108
+ "more",
109
+ "link"
110
+ ];
111
+ var ASYNC_ACTION_WORDS = [
112
+ "submit",
113
+ "save",
114
+ "send",
115
+ "upload",
116
+ "download",
117
+ "export",
118
+ "import",
119
+ "sync",
120
+ "load"
121
+ ];
122
+ var DESTRUCTIVE_WORDS = [
123
+ "delete",
124
+ "remove",
125
+ "destroy",
126
+ "clear",
127
+ "reset",
128
+ "revoke",
129
+ "terminate"
130
+ ];
131
+ var SUBMIT_WORDS = [
132
+ "submit",
133
+ "save",
134
+ "send",
135
+ "create",
136
+ "add",
137
+ "update",
138
+ "confirm",
139
+ "ok",
140
+ "done"
141
+ ];
142
+ var ERROR_WORDS = [
143
+ "error",
144
+ "fail",
145
+ "invalid",
146
+ "wrong",
147
+ "denied"
148
+ ];
149
+ var SUCCESS_WORDS = [
150
+ "success",
151
+ "saved",
152
+ "created",
153
+ "updated",
154
+ "complete"
155
+ ];
156
+ var WARNING_WORDS = [
157
+ "warning",
158
+ "caution",
159
+ "attention",
160
+ "note"
161
+ ];
162
+ var HOME_WORDS = [
163
+ "home",
164
+ "dashboard",
165
+ "main",
166
+ "start"
167
+ ];
168
+ var CLOSE_WORDS = [
169
+ "close",
170
+ "cancel",
171
+ "dismiss",
172
+ "x"
173
+ ];
174
+ var PLACEHOLDER_PATTERNS = [
175
+ "lorem ipsum",
176
+ "dolor sit amet",
177
+ "placeholder",
178
+ "sample text",
179
+ "text here",
180
+ "enter text",
181
+ "todo",
182
+ "tbd",
183
+ "xxx"
184
+ ];
185
+ var INPUT_TYPE_SUGGESTIONS = [
186
+ { keywords: ["email", "e-mail"], type: "email" },
187
+ { keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
188
+ { keywords: ["url", "website", "link"], type: "url" },
189
+ { keywords: ["password", "pwd"], type: "password" },
190
+ { keywords: ["search", "find", "query"], type: "search" },
191
+ { keywords: ["date", "birthday", "dob"], type: "date" },
192
+ { keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
193
+ ];
194
+ var FORM_INPUT_TYPES = [
195
+ "Input",
196
+ "Textarea",
197
+ "Select",
198
+ "Checkbox",
199
+ "Radio"
200
+ ];
201
+ var CONTAINER_TYPES = [
202
+ "Card",
203
+ "Section",
204
+ "Modal",
205
+ "Drawer",
206
+ "Main"
207
+ ];
208
+ var TEXT_CONTENT_TYPES = [
209
+ "Text",
210
+ "Title",
211
+ "Label"
212
+ ];
213
+
214
+ // src/utils.ts
215
+ function getNodeText(node) {
216
+ if ("content" in node && node.content) return String(node.content);
217
+ if ("text" in node && node.text) return String(node.text);
218
+ if ("label" in node && node.label) return String(node.label);
219
+ return "";
220
+ }
221
+ function getNodeTextLower(node) {
222
+ return getNodeText(node).toLowerCase();
223
+ }
224
+ function hasChildren(node) {
225
+ return "children" in node && Array.isArray(node.children);
226
+ }
227
+ function getChildren(node) {
228
+ if (hasChildren(node)) {
229
+ return node.children;
230
+ }
231
+ return [];
232
+ }
233
+ function findInChildren(node, predicate) {
234
+ const children = getChildren(node);
235
+ for (const child of children) {
236
+ if (predicate(child)) {
237
+ return child;
238
+ }
239
+ const found = findInChildren(child, predicate);
240
+ if (found) {
241
+ return found;
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+ function hasChildMatching(node, predicate) {
247
+ return findInChildren(node, predicate) !== null;
248
+ }
249
+ function countInChildren(node, predicate) {
250
+ const children = getChildren(node);
251
+ let count = 0;
252
+ for (const child of children) {
253
+ if (predicate(child)) {
254
+ count++;
255
+ }
256
+ count += countInChildren(child, predicate);
257
+ }
258
+ return count;
259
+ }
260
+ function filterChildren(node, predicate) {
261
+ const children = getChildren(node);
262
+ const result = [];
263
+ for (const child of children) {
264
+ if (predicate(child)) {
265
+ result.push(child);
266
+ }
267
+ result.push(...filterChildren(child, predicate));
268
+ }
269
+ return result;
270
+ }
271
+ function getSizeValue(node) {
272
+ if (!("size" in node)) return null;
273
+ const size = node.size;
274
+ if (typeof size === "number") return size;
275
+ if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
276
+ return null;
277
+ }
278
+ function containsAnyWord(text, words) {
279
+ const lowerText = text.toLowerCase();
280
+ return words.some((word) => lowerText.includes(word));
281
+ }
282
+ function matchesAnyWord(text, words) {
283
+ const lowerText = text.toLowerCase().trim();
284
+ return words.includes(lowerText);
285
+ }
286
+ function getNodeLocation(node) {
287
+ if ("loc" in node && node.loc && "start" in node.loc) {
288
+ return {
289
+ line: node.loc.start.line,
290
+ column: node.loc.start.column
291
+ };
292
+ }
293
+ return void 0;
294
+ }
295
+ function isNodeType(node, type) {
296
+ if (Array.isArray(type)) {
297
+ return type.includes(node.type);
298
+ }
299
+ return node.type === type;
300
+ }
301
+ function getButtonStyle(node) {
302
+ if ("primary" in node && node.primary) return "primary";
303
+ if ("secondary" in node && node.secondary) return "secondary";
304
+ if ("outline" in node && node.outline) return "outline";
305
+ if ("ghost" in node && node.ghost) return "ghost";
306
+ if ("danger" in node && node.danger) return "danger";
307
+ return "default";
308
+ }
309
+ function isAnyNode(value) {
310
+ return value !== null && typeof value === "object" && "type" in value && typeof value.type === "string";
311
+ }
312
+ function toAnyNodeArray(values) {
313
+ return values.filter(isAnyNode);
314
+ }
315
+ function getNodeItems(node) {
316
+ return "items" in node && Array.isArray(node.items) ? node.items : [];
317
+ }
318
+
34
319
  // src/rules/accessibility.ts
35
320
  var inputRequiresLabel = {
36
321
  id: "a11y-input-label",
@@ -52,7 +337,7 @@ var inputRequiresLabel = {
52
337
  suggestion: hasPlaceholder ? "Add a label attribute. Placeholder alone is not sufficient for accessibility" : "Add a label attribute to describe this field",
53
338
  path: context.path,
54
339
  nodeType: node.type,
55
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
340
+ location: getNodeLocation(node)
56
341
  };
57
342
  }
58
343
  return null;
@@ -77,7 +362,7 @@ var imageRequiresAlt = {
77
362
  suggestion: "Add an alt attribute describing the image content",
78
363
  path: context.path,
79
364
  nodeType: node.type,
80
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
365
+ location: getNodeLocation(node)
81
366
  };
82
367
  }
83
368
  return null;
@@ -92,7 +377,7 @@ var iconButtonRequiresLabel = {
92
377
  appliesTo: ["Button"],
93
378
  check: (node, context) => {
94
379
  const hasIcon = "icon" in node && node.icon;
95
- const hasContent = "content" in node && node.content && String(node.content).trim();
380
+ const hasContent = getNodeText(node).trim();
96
381
  if (hasIcon && !hasContent) {
97
382
  return {
98
383
  ruleId: "a11y-icon-button-label",
@@ -103,7 +388,7 @@ var iconButtonRequiresLabel = {
103
388
  suggestion: "Add text content or aria-label to describe the button action",
104
389
  path: context.path,
105
390
  nodeType: node.type,
106
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
391
+ location: getNodeLocation(node)
107
392
  };
108
393
  }
109
394
  return null;
@@ -117,9 +402,8 @@ var linkRequiresDescriptiveText = {
117
402
  description: "Links should have descriptive text that indicates where they lead",
118
403
  appliesTo: ["Link"],
119
404
  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())) {
405
+ const content = getNodeText(node).toLowerCase();
406
+ if (GENERIC_LINK_TEXTS.includes(content.trim())) {
123
407
  return {
124
408
  ruleId: "a11y-link-text",
125
409
  category: "accessibility",
@@ -129,7 +413,7 @@ var linkRequiresDescriptiveText = {
129
413
  suggestion: `Replace "${content}" with descriptive text that indicates the link destination`,
130
414
  path: context.path,
131
415
  nodeType: node.type,
132
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
416
+ location: getNodeLocation(node)
133
417
  };
134
418
  }
135
419
  return null;
@@ -161,7 +445,7 @@ var headingHierarchy = {
161
445
  suggestion: `Use h${prevHeadingLevel + 1} instead of h${level}, or add intermediate headings`,
162
446
  path: context.path,
163
447
  nodeType: node.type,
164
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
448
+ location: getNodeLocation(node)
165
449
  };
166
450
  }
167
451
  return null;
@@ -184,30 +468,16 @@ var formRequiresSubmit = {
184
468
  description: "Forms with input fields should have a clear submit action",
185
469
  appliesTo: ["Card", "Section", "Modal", "Main"],
186
470
  check: (node, context) => {
187
- if (!("children" in node) || !Array.isArray(node.children)) {
471
+ if (!hasChildren(node)) {
188
472
  return null;
189
473
  }
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);
474
+ const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
475
+ const hasSubmitButton = hasChildMatching(node, (child) => {
476
+ if (child.type !== "Button") return false;
477
+ const isPrimary = "primary" in child && child.primary;
478
+ const content = getNodeText(child).toLowerCase();
479
+ return isPrimary || SUBMIT_WORDS.some((w) => content.includes(w));
480
+ });
211
481
  if (hasInputs && !hasSubmitButton) {
212
482
  return {
213
483
  ruleId: "form-submit-button",
@@ -218,7 +488,7 @@ var formRequiresSubmit = {
218
488
  suggestion: 'Add a primary button with a clear action label (e.g., "Submit", "Save")',
219
489
  path: context.path,
220
490
  nodeType: node.type,
221
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
491
+ location: getNodeLocation(node)
222
492
  };
223
493
  }
224
494
  return null;
@@ -244,7 +514,7 @@ var requiredFieldIndicator = {
244
514
  suggestion: 'Add an asterisk (*) to the label or include "required" in the label text',
245
515
  path: context.path,
246
516
  nodeType: node.type,
247
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
517
+ location: getNodeLocation(node)
248
518
  };
249
519
  }
250
520
  return null;
@@ -285,7 +555,7 @@ var passwordConfirmation = {
285
555
  suggestion: 'Add a "Confirm Password" field to prevent typos',
286
556
  path: context.path,
287
557
  nodeType: node.type,
288
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
558
+ location: getNodeLocation(node)
289
559
  };
290
560
  }
291
561
  return null;
@@ -303,16 +573,7 @@ var appropriateInputType = {
303
573
  const label = "label" in node ? String(node.label || "").toLowerCase() : "";
304
574
  const placeholder = "placeholder" in node ? String(node.placeholder || "").toLowerCase() : "";
305
575
  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) {
576
+ for (const suggestion of INPUT_TYPE_SUGGESTIONS) {
316
577
  if (suggestion.keywords.some((k) => combined.includes(k)) && inputType !== suggestion.type) {
317
578
  return {
318
579
  ruleId: "form-input-type",
@@ -323,7 +584,7 @@ var appropriateInputType = {
323
584
  suggestion: `Change inputType to "${suggestion.type}"`,
324
585
  path: context.path,
325
586
  nodeType: node.type,
326
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
587
+ location: getNodeLocation(node)
327
588
  };
328
589
  }
329
590
  }
@@ -338,21 +599,6 @@ var formRules = [
338
599
  ];
339
600
 
340
601
  // 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
602
  var buttonTouchTarget = {
357
603
  id: "touch-button-size",
358
604
  category: "touch-target",
@@ -372,7 +618,7 @@ var buttonTouchTarget = {
372
618
  suggestion: `Use size="md" or larger for better touch accessibility`,
373
619
  path: context.path,
374
620
  nodeType: node.type,
375
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
621
+ location: getNodeLocation(node)
376
622
  };
377
623
  }
378
624
  return null;
@@ -401,7 +647,7 @@ var iconButtonTouchTarget = {
401
647
  suggestion: "Add padding (p=2 or more) or use a larger size",
402
648
  path: context.path,
403
649
  nodeType: node.type,
404
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
650
+ location: getNodeLocation(node)
405
651
  };
406
652
  }
407
653
  return null;
@@ -426,7 +672,7 @@ var checkboxRadioTouchTarget = {
426
672
  suggestion: "Add a label to increase the touch target area",
427
673
  path: context.path,
428
674
  nodeType: node.type,
429
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
675
+ location: getNodeLocation(node)
430
676
  };
431
677
  }
432
678
  return null;
@@ -455,7 +701,7 @@ var linkSpacing = {
455
701
  suggestion: "Add gap=2 or more to the parent row for better touch separation",
456
702
  path: context.path,
457
703
  nodeType: node.type,
458
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
704
+ location: getNodeLocation(node)
459
705
  };
460
706
  }
461
707
  }
@@ -482,7 +728,7 @@ var avatarTouchTarget = {
482
728
  suggestion: 'Use size="md" or larger for clickable avatars',
483
729
  path: context.path,
484
730
  nodeType: node.type,
485
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
731
+ location: getNodeLocation(node)
486
732
  };
487
733
  }
488
734
  return null;
@@ -497,18 +743,6 @@ var touchTargetRules = [
497
743
  ];
498
744
 
499
745
  // 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
746
  var consistentButtonStyles = {
513
747
  id: "consistency-button-styles",
514
748
  category: "consistency",
@@ -517,10 +751,10 @@ var consistentButtonStyles = {
517
751
  description: "Action buttons in the same context should use consistent styling",
518
752
  appliesTo: ["Row", "Col", "Card", "Modal"],
519
753
  check: (node, context) => {
520
- if (!("children" in node) || !Array.isArray(node.children)) {
754
+ if (!hasChildren(node)) {
521
755
  return null;
522
756
  }
523
- const buttons = node.children.filter((c) => c.type === "Button");
757
+ const buttons = getChildren(node).filter((c) => c.type === "Button");
524
758
  if (buttons.length < 2) return null;
525
759
  const styles = buttons.map((b) => getButtonStyle(b));
526
760
  const uniqueStyles = [...new Set(styles)];
@@ -536,7 +770,7 @@ var consistentButtonStyles = {
536
770
  suggestion: "Use primary for main action, outline/ghost for secondary actions",
537
771
  path: context.path,
538
772
  nodeType: node.type,
539
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
773
+ location: getNodeLocation(node)
540
774
  };
541
775
  }
542
776
  return null;
@@ -564,7 +798,7 @@ var consistentSpacing = {
564
798
  suggestion: "Use the same gap value for sibling containers",
565
799
  path: context.path,
566
800
  nodeType: node.type,
567
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
801
+ location: getNodeLocation(node)
568
802
  };
569
803
  }
570
804
  return null;
@@ -604,7 +838,7 @@ var consistentCardStyling = {
604
838
  suggestion: "Apply the same visual treatment to sibling cards",
605
839
  path: context.path,
606
840
  nodeType: node.type,
607
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
841
+ location: getNodeLocation(node)
608
842
  };
609
843
  }
610
844
  return null;
@@ -618,12 +852,9 @@ var consistentAlertVariants = {
618
852
  description: "Alerts should use appropriate variants for their purpose",
619
853
  appliesTo: ["Alert"],
620
854
  check: (node, context) => {
621
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
855
+ const content = getNodeText(node).toLowerCase();
622
856
  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") {
857
+ if (ERROR_WORDS.some((w) => content.includes(w)) && variant !== "danger") {
627
858
  return {
628
859
  ruleId: "consistency-alert-variants",
629
860
  category: "consistency",
@@ -633,10 +864,10 @@ var consistentAlertVariants = {
633
864
  suggestion: "Add variant=danger to this error alert",
634
865
  path: context.path,
635
866
  nodeType: node.type,
636
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
867
+ location: getNodeLocation(node)
637
868
  };
638
869
  }
639
- if (successWords.some((w) => content.includes(w)) && variant !== "success") {
870
+ if (SUCCESS_WORDS.some((w) => content.includes(w)) && variant !== "success") {
640
871
  return {
641
872
  ruleId: "consistency-alert-variants",
642
873
  category: "consistency",
@@ -646,10 +877,10 @@ var consistentAlertVariants = {
646
877
  suggestion: "Add variant=success to this success alert",
647
878
  path: context.path,
648
879
  nodeType: node.type,
649
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
880
+ location: getNodeLocation(node)
650
881
  };
651
882
  }
652
- if (warningWords.some((w) => content.includes(w)) && variant !== "warning") {
883
+ if (WARNING_WORDS.some((w) => content.includes(w)) && variant !== "warning") {
653
884
  return {
654
885
  ruleId: "consistency-alert-variants",
655
886
  category: "consistency",
@@ -659,7 +890,7 @@ var consistentAlertVariants = {
659
890
  suggestion: "Add variant=warning to this warning alert",
660
891
  path: context.path,
661
892
  nodeType: node.type,
662
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
893
+ location: getNodeLocation(node)
663
894
  };
664
895
  }
665
896
  return null;
@@ -691,7 +922,7 @@ var noEmptyContainers = {
691
922
  suggestion: "Add content to this container or use a placeholder to indicate intended content",
692
923
  path: context.path,
693
924
  nodeType: node.type,
694
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
925
+ location: getNodeLocation(node)
695
926
  };
696
927
  }
697
928
  return null;
@@ -705,22 +936,13 @@ var clearCTA = {
705
936
  description: "Pages should have a clear primary action for users",
706
937
  appliesTo: ["Page"],
707
938
  check: (node, context) => {
708
- if (!("children" in node) || !Array.isArray(node.children)) {
939
+ if (!hasChildren(node)) {
709
940
  return null;
710
941
  }
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);
942
+ const hasPrimaryButton = hasChildMatching(
943
+ node,
944
+ (child) => child.type === "Button" && "primary" in child && !!child.primary
945
+ );
724
946
  if (!hasPrimaryButton) {
725
947
  return {
726
948
  ruleId: "usability-clear-cta",
@@ -731,7 +953,7 @@ var clearCTA = {
731
953
  suggestion: "Add a primary button for the main action on this page",
732
954
  path: context.path,
733
955
  nodeType: node.type,
734
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
956
+ location: getNodeLocation(node)
735
957
  };
736
958
  }
737
959
  return null;
@@ -745,11 +967,10 @@ var loadingStates = {
745
967
  description: "Actions that may take time should have loading indicators",
746
968
  appliesTo: ["Button"],
747
969
  check: (node, context) => {
748
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
970
+ const content = getNodeText(node).toLowerCase();
749
971
  const hasLoading = "loading" in node;
750
972
  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) {
973
+ if (isPrimary && ASYNC_ACTION_WORDS.some((a) => content.includes(a)) && !hasLoading) {
753
974
  return {
754
975
  ruleId: "usability-loading-states",
755
976
  category: "usability",
@@ -759,7 +980,7 @@ var loadingStates = {
759
980
  suggestion: "Consider adding a loading variant for this button when action is in progress",
760
981
  path: context.path,
761
982
  nodeType: node.type,
762
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
983
+ location: getNodeLocation(node)
763
984
  };
764
985
  }
765
986
  return null;
@@ -773,10 +994,9 @@ var destructiveActionConfirmation = {
773
994
  description: "Destructive actions should have clear warning styling",
774
995
  appliesTo: ["Button"],
775
996
  check: (node, context) => {
776
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
997
+ const content = getNodeText(node).toLowerCase();
777
998
  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) {
999
+ if (DESTRUCTIVE_WORDS.some((w) => content.includes(w)) && !isDanger) {
780
1000
  return {
781
1001
  ruleId: "usability-destructive-confirm",
782
1002
  category: "usability",
@@ -786,7 +1006,7 @@ var destructiveActionConfirmation = {
786
1006
  suggestion: "Add the danger attribute to this button",
787
1007
  path: context.path,
788
1008
  nodeType: node.type,
789
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1009
+ location: getNodeLocation(node)
790
1010
  };
791
1011
  }
792
1012
  return null;
@@ -800,33 +1020,21 @@ var modalCloseButton = {
800
1020
  description: "Users should be able to close modals easily",
801
1021
  appliesTo: ["Modal"],
802
1022
  check: (node, context) => {
803
- if (!("children" in node) || !Array.isArray(node.children)) {
1023
+ if (!hasChildren(node)) {
804
1024
  return null;
805
1025
  }
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
- }
1026
+ const hasCloseButton = hasChildMatching(node, (child) => {
1027
+ if (child.type === "Button") {
1028
+ const content = getNodeText(child).toLowerCase();
1029
+ const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
1030
+ return CLOSE_WORDS.some((w) => content.includes(w) || icon.includes(w));
827
1031
  }
828
- }
829
- findCloseButton(node.children);
1032
+ if (child.type === "Icon") {
1033
+ const name = "name" in child ? String(child.name || "").toLowerCase() : "";
1034
+ return name === "x" || name === "close";
1035
+ }
1036
+ return false;
1037
+ });
830
1038
  if (!hasCloseButton) {
831
1039
  return {
832
1040
  ruleId: "usability-modal-close",
@@ -837,7 +1045,7 @@ var modalCloseButton = {
837
1045
  suggestion: 'Add a close button (icon "x") or a "Cancel" button',
838
1046
  path: context.path,
839
1047
  nodeType: node.type,
840
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1048
+ location: getNodeLocation(node)
841
1049
  };
842
1050
  }
843
1051
  return null;
@@ -851,18 +1059,126 @@ var maxNestingDepth = {
851
1059
  description: "Deeply nested layouts can be confusing and hard to maintain",
852
1060
  appliesTo: ["Row", "Col", "Card", "Section"],
853
1061
  check: (node, context) => {
854
- const MAX_DEPTH = 6;
855
- if (context.depth > MAX_DEPTH) {
1062
+ if (context.depth > MAX_NESTING_DEPTH) {
856
1063
  return {
857
1064
  ruleId: "usability-nesting-depth",
858
1065
  category: "usability",
859
1066
  severity: "warning",
860
- message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_DEPTH})`,
1067
+ message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_NESTING_DEPTH})`,
861
1068
  description: "Excessive nesting makes layouts harder to understand and maintain",
862
1069
  suggestion: "Consider flattening the layout or breaking into separate sections",
863
1070
  path: context.path,
864
1071
  nodeType: node.type,
865
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1072
+ location: getNodeLocation(node)
1073
+ };
1074
+ }
1075
+ return null;
1076
+ }
1077
+ };
1078
+ var tooManyButtons = {
1079
+ id: "usability-too-many-buttons",
1080
+ category: "usability",
1081
+ severity: "warning",
1082
+ name: "Too many buttons in container",
1083
+ description: "Too many buttons can cause decision fatigue for users",
1084
+ appliesTo: ["Card", "Section", "Row", "Modal"],
1085
+ check: (node, context) => {
1086
+ if (!hasChildren(node)) {
1087
+ return null;
1088
+ }
1089
+ const buttonCount = getChildren(node).filter((c) => c.type === "Button").length;
1090
+ if (buttonCount > MAX_BUTTONS) {
1091
+ return {
1092
+ ruleId: "usability-too-many-buttons",
1093
+ category: "usability",
1094
+ severity: "warning",
1095
+ message: `Container has ${buttonCount} buttons (recommended max: ${MAX_BUTTONS})`,
1096
+ description: "Too many choices can overwhelm users and slow decision-making",
1097
+ suggestion: "Consider grouping actions in a dropdown or prioritizing the most important actions",
1098
+ path: context.path,
1099
+ nodeType: node.type,
1100
+ location: getNodeLocation(node)
1101
+ };
1102
+ }
1103
+ return null;
1104
+ }
1105
+ };
1106
+ var tooManyFormFields = {
1107
+ id: "usability-too-many-form-fields",
1108
+ category: "usability",
1109
+ severity: "info",
1110
+ name: "Too many form fields",
1111
+ description: "Forms with many fields have higher abandonment rates",
1112
+ appliesTo: ["Card", "Section", "Main", "Modal"],
1113
+ check: (node, context) => {
1114
+ if (!hasChildren(node)) {
1115
+ return null;
1116
+ }
1117
+ const formFieldCount = countInChildren(node, (child) => FORM_INPUT_TYPES.includes(child.type));
1118
+ if (formFieldCount > MAX_FORM_FIELDS) {
1119
+ return {
1120
+ ruleId: "usability-too-many-form-fields",
1121
+ category: "usability",
1122
+ severity: "info",
1123
+ message: `Form area has ${formFieldCount} fields (recommended max: ${MAX_FORM_FIELDS})`,
1124
+ description: "Long forms increase cognitive load and abandonment rates",
1125
+ suggestion: "Consider breaking into multiple steps, using progressive disclosure, or removing optional fields",
1126
+ path: context.path,
1127
+ nodeType: node.type,
1128
+ location: getNodeLocation(node)
1129
+ };
1130
+ }
1131
+ return null;
1132
+ }
1133
+ };
1134
+ var tooManyPageElements = {
1135
+ id: "usability-page-complexity",
1136
+ category: "usability",
1137
+ severity: "info",
1138
+ name: "Page may be too complex",
1139
+ description: "Pages with too many elements can overwhelm users",
1140
+ appliesTo: ["Page"],
1141
+ check: (node, context) => {
1142
+ if (!hasChildren(node)) {
1143
+ return null;
1144
+ }
1145
+ const elementCount = countInChildren(node, () => true);
1146
+ if (elementCount > MAX_PAGE_ELEMENTS) {
1147
+ return {
1148
+ ruleId: "usability-page-complexity",
1149
+ category: "usability",
1150
+ severity: "info",
1151
+ message: `Page has ${elementCount} elements (consider if this complexity is necessary)`,
1152
+ description: "Complex pages can be overwhelming and slow to render",
1153
+ suggestion: "Consider splitting into multiple pages, using tabs, or simplifying the layout",
1154
+ path: context.path,
1155
+ nodeType: node.type,
1156
+ location: getNodeLocation(node)
1157
+ };
1158
+ }
1159
+ return null;
1160
+ }
1161
+ };
1162
+ var drawerWidth = {
1163
+ id: "usability-drawer-width",
1164
+ category: "usability",
1165
+ severity: "info",
1166
+ name: "Drawer should have appropriate width",
1167
+ description: "Drawers should have a defined width for consistent UX",
1168
+ appliesTo: ["Drawer"],
1169
+ check: (node, context) => {
1170
+ const hasWidth = "width" in node || "w" in node;
1171
+ if (!hasWidth) {
1172
+ return {
1173
+ ruleId: "usability-drawer-width",
1174
+ category: "usability",
1175
+ severity: "info",
1176
+ message: "Drawer has no width specified",
1177
+ description: "Drawers without explicit width may render inconsistently across devices",
1178
+ suggestion: 'Add a width attribute (e.g., width="320" or w="80")',
1179
+ path: context.path,
1180
+ nodeType: node.type,
1181
+ location: getNodeLocation(node)
866
1182
  };
867
1183
  }
868
1184
  return null;
@@ -874,7 +1190,11 @@ var usabilityRules = [
874
1190
  loadingStates,
875
1191
  destructiveActionConfirmation,
876
1192
  modalCloseButton,
877
- maxNestingDepth
1193
+ maxNestingDepth,
1194
+ tooManyButtons,
1195
+ tooManyFormFields,
1196
+ tooManyPageElements,
1197
+ drawerWidth
878
1198
  ];
879
1199
 
880
1200
  // src/rules/navigation.ts
@@ -886,8 +1206,7 @@ var navItemCount = {
886
1206
  description: "Navigation menus with too many items can overwhelm users",
887
1207
  appliesTo: ["Nav"],
888
1208
  check: (node, context) => {
889
- const MAX_NAV_ITEMS = 7;
890
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1209
+ const items = getNodeItems(node);
891
1210
  if (items.length > MAX_NAV_ITEMS) {
892
1211
  return {
893
1212
  ruleId: "nav-item-count",
@@ -898,7 +1217,7 @@ var navItemCount = {
898
1217
  suggestion: "Group related items into categories or use a hierarchical navigation",
899
1218
  path: context.path,
900
1219
  nodeType: node.type,
901
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1220
+ location: getNodeLocation(node)
902
1221
  };
903
1222
  }
904
1223
  return null;
@@ -912,7 +1231,7 @@ var navActiveState = {
912
1231
  description: "Users should know which page they are currently on",
913
1232
  appliesTo: ["Nav"],
914
1233
  check: (node, context) => {
915
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1234
+ const items = getNodeItems(node);
916
1235
  if (items.length === 0) return null;
917
1236
  const hasActiveItem = items.some((item) => {
918
1237
  if (typeof item === "object" && item !== null) {
@@ -930,7 +1249,7 @@ var navActiveState = {
930
1249
  suggestion: "Add active attribute to the current navigation item",
931
1250
  path: context.path,
932
1251
  nodeType: node.type,
933
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1252
+ location: getNodeLocation(node)
934
1253
  };
935
1254
  }
936
1255
  return null;
@@ -944,12 +1263,11 @@ var breadcrumbHasHome = {
944
1263
  description: "Breadcrumbs typically start with a home or root link",
945
1264
  appliesTo: ["Breadcrumb"],
946
1265
  check: (node, context) => {
947
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1266
+ const items = getNodeItems(node);
948
1267
  if (items.length === 0) return null;
949
1268
  const firstItem = items[0];
950
1269
  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))) {
1270
+ if (!HOME_WORDS.some((w) => firstLabel.includes(w))) {
953
1271
  return {
954
1272
  ruleId: "nav-breadcrumb-home",
955
1273
  category: "navigation",
@@ -959,7 +1277,7 @@ var breadcrumbHasHome = {
959
1277
  suggestion: 'Add "Home" or equivalent as the first breadcrumb item',
960
1278
  path: context.path,
961
1279
  nodeType: node.type,
962
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1280
+ location: getNodeLocation(node)
963
1281
  };
964
1282
  }
965
1283
  return null;
@@ -973,15 +1291,9 @@ var tabCount = {
973
1291
  description: "Too many tabs can be overwhelming",
974
1292
  appliesTo: ["Tabs"],
975
1293
  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;
1294
+ const items = getNodeItems(node);
1295
+ const children = hasChildren(node) ? getChildren(node) : [];
1296
+ const childTabCount = children.filter((c) => c.type.toLowerCase().includes("tab")).length;
985
1297
  const tabCount2 = items.length || childTabCount;
986
1298
  if (tabCount2 > MAX_TABS) {
987
1299
  return {
@@ -993,7 +1305,7 @@ var tabCount = {
993
1305
  suggestion: "Consider using a different navigation pattern or grouping related content",
994
1306
  path: context.path,
995
1307
  nodeType: node.type,
996
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1308
+ location: getNodeLocation(node)
997
1309
  };
998
1310
  }
999
1311
  return null;
@@ -1007,8 +1319,8 @@ var dropdownHasItems = {
1007
1319
  description: "Dropdown menus need items to be functional",
1008
1320
  appliesTo: ["Dropdown"],
1009
1321
  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 : [];
1322
+ const items = getNodeItems(node);
1323
+ const children = hasChildren(node) ? getChildren(node) : [];
1012
1324
  if (items.length === 0 && children.length === 0) {
1013
1325
  return {
1014
1326
  ruleId: "nav-dropdown-items",
@@ -1019,7 +1331,7 @@ var dropdownHasItems = {
1019
1331
  suggestion: "Add items to the dropdown menu",
1020
1332
  path: context.path,
1021
1333
  nodeType: node.type,
1022
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1334
+ location: getNodeLocation(node)
1023
1335
  };
1024
1336
  }
1025
1337
  return null;
@@ -1033,6 +1345,819 @@ var navigationRules = [
1033
1345
  dropdownHasItems
1034
1346
  ];
1035
1347
 
1348
+ // src/rules/content.ts
1349
+ var emptyTextContent = {
1350
+ id: "content-empty-text",
1351
+ category: "content",
1352
+ severity: "warning",
1353
+ name: "Avoid empty text content",
1354
+ description: "Text elements should have meaningful content",
1355
+ appliesTo: ["Text", "Title", "Label"],
1356
+ check: (node, context) => {
1357
+ const trimmed = getNodeText(node).trim();
1358
+ if (trimmed === "" || trimmed === "..." || trimmed === "Lorem ipsum") {
1359
+ return {
1360
+ ruleId: "content-empty-text",
1361
+ category: "content",
1362
+ severity: "warning",
1363
+ message: `${node.type} has placeholder or empty content`,
1364
+ description: "Placeholder text should be replaced with meaningful content",
1365
+ suggestion: "Replace with actual content or remove if not needed",
1366
+ path: context.path,
1367
+ nodeType: node.type,
1368
+ location: getNodeLocation(node)
1369
+ };
1370
+ }
1371
+ return null;
1372
+ }
1373
+ };
1374
+ var buttonTextLength = {
1375
+ id: "content-button-text-length",
1376
+ category: "content",
1377
+ severity: "info",
1378
+ name: "Button text should be concise",
1379
+ description: "Button labels should be short and action-oriented",
1380
+ appliesTo: ["Button"],
1381
+ check: (node, context) => {
1382
+ const content = getNodeText(node);
1383
+ if (content.length > MAX_BUTTON_TEXT_LENGTH) {
1384
+ return {
1385
+ ruleId: "content-button-text-length",
1386
+ category: "content",
1387
+ severity: "info",
1388
+ message: `Button text is ${content.length} characters (recommended max: ${MAX_BUTTON_TEXT_LENGTH})`,
1389
+ description: "Long button text can be hard to read and may not fit on smaller screens",
1390
+ suggestion: 'Use concise, action-oriented text (e.g., "Save" instead of "Click here to save your changes")',
1391
+ path: context.path,
1392
+ nodeType: node.type,
1393
+ location: getNodeLocation(node)
1394
+ };
1395
+ }
1396
+ return null;
1397
+ }
1398
+ };
1399
+ var titleLength = {
1400
+ id: "content-title-length",
1401
+ category: "content",
1402
+ severity: "info",
1403
+ name: "Title should be concise",
1404
+ description: "Titles should be short and descriptive",
1405
+ appliesTo: ["Title"],
1406
+ check: (node, context) => {
1407
+ const content = getNodeText(node);
1408
+ if (content.length > MAX_TITLE_LENGTH) {
1409
+ return {
1410
+ ruleId: "content-title-length",
1411
+ category: "content",
1412
+ severity: "info",
1413
+ message: `Title is ${content.length} characters (recommended max: ${MAX_TITLE_LENGTH})`,
1414
+ description: "Long titles can be hard to scan and may get truncated on smaller screens",
1415
+ suggestion: "Shorten the title and move details to a subtitle or description",
1416
+ path: context.path,
1417
+ nodeType: node.type,
1418
+ location: getNodeLocation(node)
1419
+ };
1420
+ }
1421
+ return null;
1422
+ }
1423
+ };
1424
+ var pageHasTitle = {
1425
+ id: "content-page-title",
1426
+ category: "content",
1427
+ severity: "warning",
1428
+ name: "Page should have a title",
1429
+ description: "Every page should have a clear title to orient users",
1430
+ appliesTo: ["Page"],
1431
+ check: (node, context) => {
1432
+ if (!hasChildren(node)) {
1433
+ return null;
1434
+ }
1435
+ const hasTitleElement = hasChildMatching(node, (child) => child.type === "Title");
1436
+ if (!hasTitleElement) {
1437
+ return {
1438
+ ruleId: "content-page-title",
1439
+ category: "content",
1440
+ severity: "warning",
1441
+ message: "Page has no title",
1442
+ description: "Users need a clear title to understand the page purpose",
1443
+ suggestion: "Add a Title component to identify the page",
1444
+ path: context.path,
1445
+ nodeType: node.type,
1446
+ location: getNodeLocation(node)
1447
+ };
1448
+ }
1449
+ return null;
1450
+ }
1451
+ };
1452
+ var linkHasText = {
1453
+ id: "content-link-text",
1454
+ category: "content",
1455
+ severity: "error",
1456
+ name: "Link should have text",
1457
+ description: "Links must have visible text for users to understand where they lead",
1458
+ appliesTo: ["Link"],
1459
+ check: (node, context) => {
1460
+ const content = getNodeText(node).trim();
1461
+ const hasChildElements = hasChildren(node) && getChildren(node).length > 0;
1462
+ if (!content && !hasChildElements) {
1463
+ return {
1464
+ ruleId: "content-link-text",
1465
+ category: "content",
1466
+ severity: "error",
1467
+ message: "Link has no visible text or content",
1468
+ description: "Users cannot understand or interact with links that have no text",
1469
+ suggestion: "Add descriptive text to the link",
1470
+ path: context.path,
1471
+ nodeType: node.type,
1472
+ location: getNodeLocation(node)
1473
+ };
1474
+ }
1475
+ return null;
1476
+ }
1477
+ };
1478
+ var noPlaceholderContent = {
1479
+ id: "content-no-placeholder",
1480
+ category: "content",
1481
+ severity: "warning",
1482
+ name: "Avoid placeholder content",
1483
+ description: 'Placeholder text like "Lorem ipsum" should be replaced',
1484
+ appliesTo: ["Text", "Title", "Label", "Button"],
1485
+ check: (node, context) => {
1486
+ const content = getNodeText(node).toLowerCase();
1487
+ for (const placeholder of PLACEHOLDER_PATTERNS) {
1488
+ if (content.includes(placeholder)) {
1489
+ return {
1490
+ ruleId: "content-no-placeholder",
1491
+ category: "content",
1492
+ severity: "warning",
1493
+ message: `${node.type} contains placeholder text "${placeholder}"`,
1494
+ description: "Placeholder text should be replaced before production",
1495
+ suggestion: "Replace with actual content",
1496
+ path: context.path,
1497
+ nodeType: node.type,
1498
+ location: getNodeLocation(node)
1499
+ };
1500
+ }
1501
+ }
1502
+ return null;
1503
+ }
1504
+ };
1505
+ var contentRules = [
1506
+ emptyTextContent,
1507
+ buttonTextLength,
1508
+ titleLength,
1509
+ pageHasTitle,
1510
+ linkHasText,
1511
+ noPlaceholderContent
1512
+ ];
1513
+
1514
+ // src/rules/data-display.ts
1515
+ var tableHasHeader = {
1516
+ id: "data-table-header",
1517
+ category: "data-display",
1518
+ severity: "warning",
1519
+ name: "Table should have header",
1520
+ description: "Tables should have a header row to identify columns",
1521
+ appliesTo: ["Table"],
1522
+ check: (node, context) => {
1523
+ const hasHeader = "header" in node && node.header;
1524
+ const hasColumns = "columns" in node && Array.isArray(node.columns) && node.columns.length > 0;
1525
+ if (!hasHeader && !hasColumns) {
1526
+ return {
1527
+ ruleId: "data-table-header",
1528
+ category: "data-display",
1529
+ severity: "warning",
1530
+ message: "Table has no header or columns defined",
1531
+ description: "Table headers help users understand what each column represents",
1532
+ suggestion: "Add a header attribute or define columns for the table",
1533
+ path: context.path,
1534
+ nodeType: node.type,
1535
+ location: getNodeLocation(node)
1536
+ };
1537
+ }
1538
+ return null;
1539
+ }
1540
+ };
1541
+ var listEmptyState = {
1542
+ id: "data-list-empty-state",
1543
+ category: "data-display",
1544
+ severity: "info",
1545
+ name: "Consider empty state for list",
1546
+ description: "Lists should indicate when they have no items",
1547
+ appliesTo: ["List"],
1548
+ check: (node, context) => {
1549
+ const items = getNodeItems(node);
1550
+ const children = hasChildren(node) ? getChildren(node) : [];
1551
+ const hasEmptyStateAttr = "emptyState" in node || "empty" in node;
1552
+ if (items.length > 0 || children.length > 0) {
1553
+ return null;
1554
+ }
1555
+ if (!hasEmptyStateAttr) {
1556
+ return {
1557
+ ruleId: "data-list-empty-state",
1558
+ category: "data-display",
1559
+ severity: "info",
1560
+ message: "Empty list has no empty state defined",
1561
+ description: "Users should see a helpful message when lists are empty",
1562
+ suggestion: "Add an emptyState attribute or component to show when list is empty",
1563
+ path: context.path,
1564
+ nodeType: node.type,
1565
+ location: getNodeLocation(node)
1566
+ };
1567
+ }
1568
+ return null;
1569
+ }
1570
+ };
1571
+ var tableEmptyState = {
1572
+ id: "data-table-empty-state",
1573
+ category: "data-display",
1574
+ severity: "info",
1575
+ name: "Consider empty state for table",
1576
+ description: "Tables should indicate when they have no data",
1577
+ appliesTo: ["Table"],
1578
+ check: (node, context) => {
1579
+ const rows = "rows" in node && Array.isArray(node.rows) ? node.rows : [];
1580
+ const data = "data" in node && Array.isArray(node.data) ? node.data : [];
1581
+ const hasEmptyState = "emptyState" in node || "empty" in node;
1582
+ if (rows.length > 0 || data.length > 0) {
1583
+ return null;
1584
+ }
1585
+ if (!hasEmptyState) {
1586
+ return {
1587
+ ruleId: "data-table-empty-state",
1588
+ category: "data-display",
1589
+ severity: "info",
1590
+ message: "Empty table has no empty state defined",
1591
+ description: "Users should see a helpful message when tables have no data",
1592
+ suggestion: "Add an emptyState attribute to show when table is empty",
1593
+ path: context.path,
1594
+ nodeType: node.type,
1595
+ location: getNodeLocation(node)
1596
+ };
1597
+ }
1598
+ return null;
1599
+ }
1600
+ };
1601
+ var listTooManyItems = {
1602
+ id: "data-list-pagination",
1603
+ category: "data-display",
1604
+ severity: "info",
1605
+ name: "Long list may need pagination",
1606
+ description: "Lists with many items should consider pagination or virtualization",
1607
+ appliesTo: ["List"],
1608
+ check: (node, context) => {
1609
+ const items = getNodeItems(node);
1610
+ const children = hasChildren(node) ? getChildren(node) : [];
1611
+ const hasPagination = "pagination" in node || "paginated" in node;
1612
+ const itemCount = items.length || children.filter((c) => isNodeType(c, "ListItem")).length;
1613
+ if (itemCount > MAX_LIST_ITEMS && !hasPagination) {
1614
+ return {
1615
+ ruleId: "data-list-pagination",
1616
+ category: "data-display",
1617
+ severity: "info",
1618
+ message: `List has ${itemCount} items (consider pagination for better performance)`,
1619
+ description: "Long lists can be slow to render and overwhelming for users",
1620
+ suggestion: "Add pagination, infinite scroll, or virtualization for long lists",
1621
+ path: context.path,
1622
+ nodeType: node.type,
1623
+ location: getNodeLocation(node)
1624
+ };
1625
+ }
1626
+ return null;
1627
+ }
1628
+ };
1629
+ var tableTooManyColumns = {
1630
+ id: "data-table-columns",
1631
+ category: "data-display",
1632
+ severity: "warning",
1633
+ name: "Table has too many columns",
1634
+ description: "Tables with many columns are hard to read on smaller screens",
1635
+ appliesTo: ["Table"],
1636
+ check: (node, context) => {
1637
+ const columns = "columns" in node && Array.isArray(node.columns) ? node.columns : [];
1638
+ if (columns.length > MAX_TABLE_COLUMNS) {
1639
+ return {
1640
+ ruleId: "data-table-columns",
1641
+ category: "data-display",
1642
+ severity: "warning",
1643
+ message: `Table has ${columns.length} columns (recommended max: ${MAX_TABLE_COLUMNS})`,
1644
+ description: "Tables with many columns are difficult to read and may not fit on mobile",
1645
+ suggestion: "Consider hiding less important columns on mobile or using a different layout",
1646
+ path: context.path,
1647
+ nodeType: node.type,
1648
+ location: getNodeLocation(node)
1649
+ };
1650
+ }
1651
+ return null;
1652
+ }
1653
+ };
1654
+ var cardGridConsistency = {
1655
+ id: "data-card-grid",
1656
+ category: "data-display",
1657
+ severity: "info",
1658
+ name: "Card grid should have consistent items",
1659
+ description: "Cards in a grid layout should have consistent dimensions",
1660
+ appliesTo: ["Row"],
1661
+ check: (node, context) => {
1662
+ if (!hasChildren(node)) {
1663
+ return null;
1664
+ }
1665
+ const cards = getChildren(node).filter((c) => c.type === "Card");
1666
+ if (cards.length < 3) return null;
1667
+ const heights = cards.map((c) => "height" in c ? c.height : "h" in c ? c.h : null);
1668
+ const definedHeights = heights.filter((h) => h !== null);
1669
+ const uniqueHeights = [...new Set(definedHeights)];
1670
+ if (uniqueHeights.length > 1) {
1671
+ return {
1672
+ ruleId: "data-card-grid",
1673
+ category: "data-display",
1674
+ severity: "info",
1675
+ message: "Card grid has inconsistent card heights",
1676
+ description: "Inconsistent card sizes can create visual imbalance",
1677
+ suggestion: "Consider using consistent heights for cards in a grid layout",
1678
+ path: context.path,
1679
+ nodeType: node.type,
1680
+ location: getNodeLocation(node)
1681
+ };
1682
+ }
1683
+ return null;
1684
+ }
1685
+ };
1686
+ var dataDisplayRules = [
1687
+ tableHasHeader,
1688
+ listEmptyState,
1689
+ tableEmptyState,
1690
+ listTooManyItems,
1691
+ tableTooManyColumns,
1692
+ cardGridConsistency
1693
+ ];
1694
+
1695
+ // src/rules/feedback.ts
1696
+ var spinnerHasContext = {
1697
+ id: "feedback-spinner-context",
1698
+ category: "feedback",
1699
+ severity: "info",
1700
+ name: "Spinner should have context",
1701
+ description: "Loading spinners should indicate what is being loaded",
1702
+ appliesTo: ["Spinner"],
1703
+ check: (node, context) => {
1704
+ const hasText = "text" in node && node.text;
1705
+ const hasLabel = "label" in node && node.label;
1706
+ const hasContent = "content" in node && node.content;
1707
+ const siblingText = context.siblings.some(
1708
+ (s) => s.type === "Text" && "content" in s && String(s.content || "").toLowerCase().includes("loading")
1709
+ );
1710
+ if (!hasText && !hasLabel && !hasContent && !siblingText) {
1711
+ return {
1712
+ ruleId: "feedback-spinner-context",
1713
+ category: "feedback",
1714
+ severity: "info",
1715
+ message: "Spinner has no loading text",
1716
+ description: "Users benefit from knowing what is being loaded",
1717
+ suggestion: 'Add a text/label attribute like "Loading..." or "Please wait..."',
1718
+ path: context.path,
1719
+ nodeType: node.type,
1720
+ location: getNodeLocation(node)
1721
+ };
1722
+ }
1723
+ return null;
1724
+ }
1725
+ };
1726
+ var progressHasValue = {
1727
+ id: "feedback-progress-value",
1728
+ category: "feedback",
1729
+ severity: "info",
1730
+ name: "Progress should show value",
1731
+ description: "Progress bars should indicate completion percentage",
1732
+ appliesTo: ["Progress"],
1733
+ check: (node, context) => {
1734
+ const hasValue = "value" in node;
1735
+ const hasPercent = "percent" in node;
1736
+ const isIndeterminate = "indeterminate" in node && node.indeterminate;
1737
+ if (isIndeterminate) return null;
1738
+ if (!hasValue && !hasPercent) {
1739
+ return {
1740
+ ruleId: "feedback-progress-value",
1741
+ category: "feedback",
1742
+ severity: "info",
1743
+ message: "Progress bar has no value specified",
1744
+ description: "Users should see how much progress has been made",
1745
+ suggestion: "Add a value attribute (0-100) or use indeterminate for unknown duration",
1746
+ path: context.path,
1747
+ nodeType: node.type,
1748
+ location: getNodeLocation(node)
1749
+ };
1750
+ }
1751
+ return null;
1752
+ }
1753
+ };
1754
+ var toastDuration = {
1755
+ id: "feedback-toast-duration",
1756
+ category: "feedback",
1757
+ severity: "info",
1758
+ name: "Toast should have appropriate duration",
1759
+ description: "Toasts should auto-dismiss after a reasonable time",
1760
+ appliesTo: ["Toast"],
1761
+ check: (node, context) => {
1762
+ const duration = "duration" in node ? Number(node.duration) : null;
1763
+ if (duration !== null) {
1764
+ if (duration < MIN_TOAST_DURATION) {
1765
+ return {
1766
+ ruleId: "feedback-toast-duration",
1767
+ category: "feedback",
1768
+ severity: "info",
1769
+ message: `Toast duration (${duration}ms) may be too short to read`,
1770
+ description: "Users need time to read toast messages",
1771
+ suggestion: `Increase duration to at least ${MIN_TOAST_DURATION}ms`,
1772
+ path: context.path,
1773
+ nodeType: node.type,
1774
+ location: getNodeLocation(node)
1775
+ };
1776
+ }
1777
+ if (duration > MAX_TOAST_DURATION) {
1778
+ return {
1779
+ ruleId: "feedback-toast-duration",
1780
+ category: "feedback",
1781
+ severity: "info",
1782
+ message: `Toast duration (${duration}ms) may be too long`,
1783
+ description: "Long-lasting toasts can be annoying and block UI",
1784
+ suggestion: `Consider reducing duration or using a persistent alert instead`,
1785
+ path: context.path,
1786
+ nodeType: node.type,
1787
+ location: getNodeLocation(node)
1788
+ };
1789
+ }
1790
+ }
1791
+ return null;
1792
+ }
1793
+ };
1794
+ var alertDismissible = {
1795
+ id: "feedback-alert-dismissible",
1796
+ category: "feedback",
1797
+ severity: "info",
1798
+ name: "Non-critical alerts should be dismissible",
1799
+ description: "Info and success alerts should be dismissible by users",
1800
+ appliesTo: ["Alert"],
1801
+ check: (node, context) => {
1802
+ const variant = "variant" in node ? String(node.variant || "") : "";
1803
+ const isDismissible = "dismissible" in node || "closable" in node || "onClose" in node;
1804
+ if (variant === "danger" || variant === "error") return null;
1805
+ if ((variant === "info" || variant === "success") && !isDismissible) {
1806
+ return {
1807
+ ruleId: "feedback-alert-dismissible",
1808
+ category: "feedback",
1809
+ severity: "info",
1810
+ message: `${variant} alert is not dismissible`,
1811
+ description: "Users should be able to dismiss non-critical alerts",
1812
+ suggestion: "Add dismissible or closable attribute to allow users to close the alert",
1813
+ path: context.path,
1814
+ nodeType: node.type,
1815
+ location: getNodeLocation(node)
1816
+ };
1817
+ }
1818
+ return null;
1819
+ }
1820
+ };
1821
+ var tooltipContentLength = {
1822
+ id: "feedback-tooltip-length",
1823
+ category: "feedback",
1824
+ severity: "info",
1825
+ name: "Tooltip content should be brief",
1826
+ description: "Tooltips should be short and helpful",
1827
+ appliesTo: ["Tooltip"],
1828
+ check: (node, context) => {
1829
+ const tooltipText = getNodeText(node);
1830
+ if (tooltipText.length > MAX_TOOLTIP_LENGTH) {
1831
+ return {
1832
+ ruleId: "feedback-tooltip-length",
1833
+ category: "feedback",
1834
+ severity: "info",
1835
+ message: `Tooltip is ${tooltipText.length} characters (recommended max: ${MAX_TOOLTIP_LENGTH})`,
1836
+ description: "Long tooltips are hard to read and may disappear before being fully read",
1837
+ suggestion: "Keep tooltips brief or use a popover for longer content",
1838
+ path: context.path,
1839
+ nodeType: node.type,
1840
+ location: getNodeLocation(node)
1841
+ };
1842
+ }
1843
+ return null;
1844
+ }
1845
+ };
1846
+ var formErrorFeedback = {
1847
+ id: "feedback-form-errors",
1848
+ category: "feedback",
1849
+ severity: "info",
1850
+ name: "Form should handle errors",
1851
+ description: "Forms should have a way to display validation errors",
1852
+ appliesTo: ["Card", "Section", "Modal"],
1853
+ check: (node, context) => {
1854
+ if (!hasChildren(node)) {
1855
+ return null;
1856
+ }
1857
+ const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
1858
+ const hasErrorDisplay = hasChildMatching(node, (child) => {
1859
+ if (FORM_INPUT_TYPES.includes(child.type)) {
1860
+ if ("error" in child || "errorText" in child || "helperText" in child) {
1861
+ return true;
1862
+ }
1863
+ }
1864
+ if (child.type === "Alert" && "variant" in child) {
1865
+ const variant = String(child.variant || "");
1866
+ if (variant === "error" || variant === "danger") {
1867
+ return true;
1868
+ }
1869
+ }
1870
+ if (child.type === "Text") {
1871
+ const content = getNodeText(child).toLowerCase();
1872
+ if (content.includes("error") || content.includes("invalid")) {
1873
+ return true;
1874
+ }
1875
+ }
1876
+ return false;
1877
+ });
1878
+ if (hasInputs && !hasErrorDisplay) {
1879
+ return {
1880
+ ruleId: "feedback-form-errors",
1881
+ category: "feedback",
1882
+ severity: "info",
1883
+ message: "Form area has no visible error handling",
1884
+ description: "Users need to see validation errors when they occur",
1885
+ suggestion: "Add error/errorText attributes to inputs or include an Alert for form-level errors",
1886
+ path: context.path,
1887
+ nodeType: node.type,
1888
+ location: getNodeLocation(node)
1889
+ };
1890
+ }
1891
+ return null;
1892
+ }
1893
+ };
1894
+ var feedbackRules = [
1895
+ spinnerHasContext,
1896
+ progressHasValue,
1897
+ toastDuration,
1898
+ alertDismissible,
1899
+ tooltipContentLength,
1900
+ formErrorFeedback
1901
+ ];
1902
+
1903
+ // src/rules/interaction.ts
1904
+ var buttonHasAction = {
1905
+ id: "interaction-button-action",
1906
+ category: "interaction",
1907
+ severity: "warning",
1908
+ name: "Button should have action",
1909
+ description: "Buttons should have an onClick, action, or navigation target defined",
1910
+ appliesTo: ["Button"],
1911
+ check: (node, context) => {
1912
+ const hasOnClick = "onClick" in node;
1913
+ const hasAction = "action" in node;
1914
+ const hasHref = "href" in node;
1915
+ const hasTo = "to" in node;
1916
+ const hasNavigate = "navigate" in node;
1917
+ const hasLink = "link" in node;
1918
+ const hasSubmit = "buttonType" in node && node.buttonType === "submit";
1919
+ const hasFormSubmit = "submit" in node;
1920
+ const buttonText = getNodeText(node);
1921
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasNavigate && !hasLink && !hasSubmit && !hasFormSubmit) {
1922
+ return {
1923
+ ruleId: "interaction-button-action",
1924
+ category: "interaction",
1925
+ severity: "warning",
1926
+ message: buttonText ? `Button "${buttonText}" has no action defined` : "Button has no action defined",
1927
+ description: "Interactive buttons need an action to respond to user clicks",
1928
+ suggestion: "Add onClick, action, href, or navigate attribute to define what happens when clicked",
1929
+ path: context.path,
1930
+ nodeType: node.type,
1931
+ location: getNodeLocation(node)
1932
+ };
1933
+ }
1934
+ return null;
1935
+ }
1936
+ };
1937
+ var linkHasDestination = {
1938
+ id: "interaction-link-destination",
1939
+ category: "interaction",
1940
+ severity: "warning",
1941
+ name: "Link should have destination",
1942
+ description: "Links should have an href or navigation target defined",
1943
+ appliesTo: ["Link", "NavLink", "Anchor"],
1944
+ check: (node, context) => {
1945
+ const hasHref = "href" in node && node.href;
1946
+ const hasTo = "to" in node && node.to;
1947
+ const hasNavigate = "navigate" in node;
1948
+ const hasUrl = "url" in node && node.url;
1949
+ const linkText = getNodeText(node);
1950
+ if (!hasHref && !hasTo && !hasNavigate && !hasUrl) {
1951
+ return {
1952
+ ruleId: "interaction-link-destination",
1953
+ category: "interaction",
1954
+ severity: "warning",
1955
+ message: linkText ? `Link "${linkText}" has no destination` : "Link has no destination defined",
1956
+ description: "Links need a destination URL or route to navigate to",
1957
+ suggestion: "Add href, to, or navigate attribute to define the link destination",
1958
+ path: context.path,
1959
+ nodeType: node.type,
1960
+ location: getNodeLocation(node)
1961
+ };
1962
+ }
1963
+ return null;
1964
+ }
1965
+ };
1966
+ var formHasSubmitAction = {
1967
+ id: "interaction-form-submit",
1968
+ category: "interaction",
1969
+ severity: "warning",
1970
+ name: "Form should have submit action",
1971
+ description: "Forms should have an onSubmit action or form action defined",
1972
+ appliesTo: ["Form"],
1973
+ check: (node, context) => {
1974
+ const hasOnSubmit = "onSubmit" in node;
1975
+ const hasAction = "action" in node && node.action;
1976
+ if (!hasOnSubmit && !hasAction) {
1977
+ return {
1978
+ ruleId: "interaction-form-submit",
1979
+ category: "interaction",
1980
+ severity: "warning",
1981
+ message: "Form has no submit action defined",
1982
+ description: "Forms need an action to handle submission",
1983
+ suggestion: "Add onSubmit or action attribute to define form submission behavior",
1984
+ path: context.path,
1985
+ nodeType: node.type,
1986
+ location: getNodeLocation(node)
1987
+ };
1988
+ }
1989
+ return null;
1990
+ }
1991
+ };
1992
+ var tabItemHasTarget = {
1993
+ id: "interaction-tab-target",
1994
+ category: "interaction",
1995
+ severity: "warning",
1996
+ name: "Tab item should have target",
1997
+ description: "Tab items should link to content or have an action",
1998
+ appliesTo: ["Tab", "TabItem"],
1999
+ check: (node, context) => {
2000
+ const hasTarget = "target" in node;
2001
+ const hasPanel = "panel" in node;
2002
+ const hasContent = "content" in node;
2003
+ const hasOnClick = "onClick" in node;
2004
+ const hasValue = "value" in node;
2005
+ const tabLabel = getNodeText(node);
2006
+ if (!hasTarget && !hasPanel && !hasContent && !hasOnClick && !hasValue) {
2007
+ return {
2008
+ ruleId: "interaction-tab-target",
2009
+ category: "interaction",
2010
+ severity: "warning",
2011
+ message: tabLabel ? `Tab "${tabLabel}" has no target or action` : "Tab item has no target or action defined",
2012
+ description: "Tab items need to reference content or trigger an action",
2013
+ suggestion: "Add target, panel, value, or onClick attribute",
2014
+ path: context.path,
2015
+ nodeType: node.type,
2016
+ location: getNodeLocation(node)
2017
+ };
2018
+ }
2019
+ return null;
2020
+ }
2021
+ };
2022
+ var menuItemHasAction = {
2023
+ id: "interaction-menu-action",
2024
+ category: "interaction",
2025
+ severity: "warning",
2026
+ name: "Menu item should have action",
2027
+ description: "Menu items should have an onClick, href, or action defined",
2028
+ appliesTo: ["MenuItem", "DropdownItem"],
2029
+ check: (node, context) => {
2030
+ const hasOnClick = "onClick" in node;
2031
+ const hasAction = "action" in node;
2032
+ const hasHref = "href" in node;
2033
+ const hasTo = "to" in node;
2034
+ const hasCommand = "command" in node;
2035
+ const itemText = getNodeText(node);
2036
+ if ("divider" in node || "separator" in node) {
2037
+ return null;
2038
+ }
2039
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasCommand) {
2040
+ return {
2041
+ ruleId: "interaction-menu-action",
2042
+ category: "interaction",
2043
+ severity: "warning",
2044
+ message: itemText ? `Menu item "${itemText}" has no action` : "Menu item has no action defined",
2045
+ description: "Menu items need an action to respond to selection",
2046
+ suggestion: "Add onClick, action, href, or command attribute",
2047
+ path: context.path,
2048
+ nodeType: node.type,
2049
+ location: getNodeLocation(node)
2050
+ };
2051
+ }
2052
+ return null;
2053
+ }
2054
+ };
2055
+ var clickableCardHasAction = {
2056
+ id: "interaction-card-action",
2057
+ category: "interaction",
2058
+ severity: "info",
2059
+ name: "Clickable card should have action",
2060
+ description: "Cards marked as clickable should have an action defined",
2061
+ appliesTo: ["Card"],
2062
+ check: (node, context) => {
2063
+ const isClickable = "clickable" in node || "hoverable" in node || "interactive" in node;
2064
+ if (!isClickable) return null;
2065
+ const hasOnClick = "onClick" in node;
2066
+ const hasHref = "href" in node;
2067
+ const hasTo = "to" in node;
2068
+ const hasAction = "action" in node;
2069
+ if (!hasOnClick && !hasHref && !hasTo && !hasAction) {
2070
+ return {
2071
+ ruleId: "interaction-card-action",
2072
+ category: "interaction",
2073
+ severity: "info",
2074
+ message: "Clickable card has no action defined",
2075
+ description: "Cards marked as clickable/interactive need an action",
2076
+ suggestion: "Add onClick, href, or action attribute to define click behavior",
2077
+ path: context.path,
2078
+ nodeType: node.type,
2079
+ location: getNodeLocation(node)
2080
+ };
2081
+ }
2082
+ return null;
2083
+ }
2084
+ };
2085
+ var iconButtonHasAction = {
2086
+ id: "interaction-icon-button-action",
2087
+ category: "interaction",
2088
+ severity: "warning",
2089
+ name: "Icon button should have action",
2090
+ description: "Icon buttons should have an action defined",
2091
+ appliesTo: ["IconButton"],
2092
+ check: (node, context) => {
2093
+ const hasOnClick = "onClick" in node;
2094
+ const hasAction = "action" in node;
2095
+ const hasHref = "href" in node;
2096
+ const hasTo = "to" in node;
2097
+ const iconName = "icon" in node ? String(node.icon || "") : "name" in node ? String(node.name || "") : "";
2098
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo) {
2099
+ return {
2100
+ ruleId: "interaction-icon-button-action",
2101
+ category: "interaction",
2102
+ severity: "warning",
2103
+ message: iconName ? `Icon button "${iconName}" has no action defined` : "Icon button has no action defined",
2104
+ description: "Icon buttons need an action to respond to clicks",
2105
+ suggestion: "Add onClick, action, or href attribute",
2106
+ path: context.path,
2107
+ nodeType: node.type,
2108
+ location: getNodeLocation(node)
2109
+ };
2110
+ }
2111
+ return null;
2112
+ }
2113
+ };
2114
+ var modalHasCloseAction = {
2115
+ id: "interaction-modal-close",
2116
+ category: "interaction",
2117
+ severity: "info",
2118
+ name: "Modal should have close mechanism",
2119
+ description: "Modals should have a way to close them",
2120
+ appliesTo: ["Modal", "Dialog"],
2121
+ check: (node, context) => {
2122
+ const hasOnClose = "onClose" in node;
2123
+ const hasCloseButton = "closable" in node || "closeButton" in node;
2124
+ const hasDismiss = "dismissible" in node || "dismiss" in node;
2125
+ const hasCloseChild = hasChildren(node) && hasChildMatching(node, (child) => {
2126
+ if (child.type === "Button") {
2127
+ const text = "text" in child ? String(child.text || "").toLowerCase() : "";
2128
+ const label = "label" in child ? String(child.label || "").toLowerCase() : "";
2129
+ const action = "action" in child ? String(child.action || "").toLowerCase() : "";
2130
+ return text.includes("close") || text.includes("cancel") || label.includes("close") || label.includes("cancel") || action.includes("close");
2131
+ }
2132
+ return false;
2133
+ });
2134
+ if (!hasOnClose && !hasCloseButton && !hasDismiss && !hasCloseChild) {
2135
+ return {
2136
+ ruleId: "interaction-modal-close",
2137
+ category: "interaction",
2138
+ severity: "info",
2139
+ message: "Modal has no close mechanism defined",
2140
+ description: "Users need a way to close modals",
2141
+ suggestion: "Add onClose, closable attribute, or a close/cancel button",
2142
+ path: context.path,
2143
+ nodeType: node.type,
2144
+ location: getNodeLocation(node)
2145
+ };
2146
+ }
2147
+ return null;
2148
+ }
2149
+ };
2150
+ var interactionRules = [
2151
+ buttonHasAction,
2152
+ linkHasDestination,
2153
+ formHasSubmitAction,
2154
+ tabItemHasTarget,
2155
+ menuItemHasAction,
2156
+ clickableCardHasAction,
2157
+ iconButtonHasAction,
2158
+ modalHasCloseAction
2159
+ ];
2160
+
1036
2161
  // src/rules/index.ts
1037
2162
  var allRules = [
1038
2163
  ...accessibilityRules,
@@ -1040,7 +2165,11 @@ var allRules = [
1040
2165
  ...touchTargetRules,
1041
2166
  ...consistencyRules,
1042
2167
  ...usabilityRules,
1043
- ...navigationRules
2168
+ ...navigationRules,
2169
+ ...contentRules,
2170
+ ...dataDisplayRules,
2171
+ ...feedbackRules,
2172
+ ...interactionRules
1044
2173
  ];
1045
2174
  var rulesByCategory = {
1046
2175
  accessibility: accessibilityRules,
@@ -1048,7 +2177,11 @@ var rulesByCategory = {
1048
2177
  "touch-target": touchTargetRules,
1049
2178
  consistency: consistencyRules,
1050
2179
  usability: usabilityRules,
1051
- navigation: navigationRules
2180
+ navigation: navigationRules,
2181
+ content: contentRules,
2182
+ "data-display": dataDisplayRules,
2183
+ feedback: feedbackRules,
2184
+ interaction: interactionRules
1052
2185
  };
1053
2186
  function getRulesForCategories(categories) {
1054
2187
  if (categories.length === 0) return allRules;
@@ -1072,7 +2205,6 @@ function validateUX(ast, options = {}) {
1072
2205
  customRules = [],
1073
2206
  disabledRules = []
1074
2207
  } = options;
1075
- resetConsistencyTrackers();
1076
2208
  let rules = categories.length > 0 ? getRulesForCategories(categories) : allRules;
1077
2209
  rules = [...rules, ...customRules];
1078
2210
  if (disabledRules.length > 0) {
@@ -1109,10 +2241,11 @@ function validateUX(ast, options = {}) {
1109
2241
  return true;
1110
2242
  }
1111
2243
  function walkNode(node, path, parent, siblings, index, depth) {
2244
+ const rootNode = ast.children?.[0];
1112
2245
  const context = {
1113
2246
  path,
1114
2247
  parent,
1115
- root: ast.children[0],
2248
+ root: isAnyNode(rootNode) ? rootNode : null,
1116
2249
  siblings,
1117
2250
  index,
1118
2251
  depth
@@ -1157,9 +2290,9 @@ function validateUX(ast, options = {}) {
1157
2290
  return true;
1158
2291
  }
1159
2292
  if (ast.children) {
1160
- for (let i = 0; i < ast.children.length; i++) {
1161
- const page = ast.children[i];
1162
- const children = ast.children;
2293
+ const children = toAnyNodeArray(ast.children);
2294
+ for (let i = 0; i < children.length; i++) {
2295
+ const page = children[i];
1163
2296
  walkNode(page, `pages[${i}]`, null, children, i, 0);
1164
2297
  }
1165
2298
  }
@@ -1225,12 +2358,59 @@ function formatUXResult(result) {
1225
2358
  }
1226
2359
  // Annotate the CommonJS export names for ESM import in node:
1227
2360
  0 && (module.exports = {
2361
+ ASYNC_ACTION_WORDS,
2362
+ CLOSE_WORDS,
2363
+ CONTAINER_TYPES,
2364
+ DESTRUCTIVE_WORDS,
2365
+ ERROR_WORDS,
2366
+ FORM_INPUT_TYPES,
2367
+ GENERIC_LINK_TEXTS,
2368
+ HOME_WORDS,
2369
+ INPUT_TYPE_SUGGESTIONS,
2370
+ MAX_BUTTONS,
2371
+ MAX_BUTTON_TEXT_LENGTH,
2372
+ MAX_FORM_FIELDS,
2373
+ MAX_LIST_ITEMS,
2374
+ MAX_NAV_ITEMS,
2375
+ MAX_NESTING_DEPTH,
2376
+ MAX_PAGE_ELEMENTS,
2377
+ MAX_TABLE_COLUMNS,
2378
+ MAX_TABS,
2379
+ MAX_TITLE_LENGTH,
2380
+ MAX_TOAST_DURATION,
2381
+ MAX_TOOLTIP_LENGTH,
2382
+ MIN_TOAST_DURATION,
2383
+ MIN_TOUCH_TARGET,
2384
+ PLACEHOLDER_PATTERNS,
2385
+ RECOMMENDED_TOUCH_TARGET,
2386
+ SIZE_MAP,
2387
+ SUBMIT_WORDS,
2388
+ SUCCESS_WORDS,
2389
+ TEXT_CONTENT_TYPES,
2390
+ WARNING_WORDS,
1228
2391
  allRules,
2392
+ containsAnyWord,
2393
+ countInChildren,
2394
+ filterChildren,
2395
+ findInChildren,
1229
2396
  formatUXResult,
2397
+ getButtonStyle,
2398
+ getChildren,
2399
+ getNodeItems,
2400
+ getNodeLocation,
2401
+ getNodeText,
2402
+ getNodeTextLower,
1230
2403
  getRulesForCategories,
2404
+ getSizeValue,
1231
2405
  getUXIssues,
1232
2406
  getUXScore,
2407
+ hasChildMatching,
2408
+ hasChildren,
2409
+ isAnyNode,
2410
+ isNodeType,
1233
2411
  isUXValid,
2412
+ matchesAnyWord,
1234
2413
  rulesByCategory,
2414
+ toAnyNodeArray,
1235
2415
  validateUX
1236
2416
  });