@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.js CHANGED
@@ -1,3 +1,241 @@
1
+ // src/constants.ts
2
+ var MAX_NAV_ITEMS = 7;
3
+ var MAX_TABS = 5;
4
+ var MAX_NESTING_DEPTH = 6;
5
+ var MAX_BUTTONS = 5;
6
+ var MAX_FORM_FIELDS = 10;
7
+ var MAX_PAGE_ELEMENTS = 50;
8
+ var MAX_BUTTON_TEXT_LENGTH = 25;
9
+ var MAX_TITLE_LENGTH = 60;
10
+ var MAX_TOOLTIP_LENGTH = 100;
11
+ var MAX_LIST_ITEMS = 20;
12
+ var MAX_TABLE_COLUMNS = 8;
13
+ var MIN_TOUCH_TARGET = 44;
14
+ var RECOMMENDED_TOUCH_TARGET = 48;
15
+ var SIZE_MAP = {
16
+ xs: 24,
17
+ sm: 32,
18
+ md: 40,
19
+ lg: 48,
20
+ xl: 56
21
+ };
22
+ var MIN_TOAST_DURATION = 2e3;
23
+ var MAX_TOAST_DURATION = 1e4;
24
+ var GENERIC_LINK_TEXTS = [
25
+ "click here",
26
+ "here",
27
+ "read more",
28
+ "more",
29
+ "link"
30
+ ];
31
+ var ASYNC_ACTION_WORDS = [
32
+ "submit",
33
+ "save",
34
+ "send",
35
+ "upload",
36
+ "download",
37
+ "export",
38
+ "import",
39
+ "sync",
40
+ "load"
41
+ ];
42
+ var DESTRUCTIVE_WORDS = [
43
+ "delete",
44
+ "remove",
45
+ "destroy",
46
+ "clear",
47
+ "reset",
48
+ "revoke",
49
+ "terminate"
50
+ ];
51
+ var SUBMIT_WORDS = [
52
+ "submit",
53
+ "save",
54
+ "send",
55
+ "create",
56
+ "add",
57
+ "update",
58
+ "confirm",
59
+ "ok",
60
+ "done"
61
+ ];
62
+ var ERROR_WORDS = [
63
+ "error",
64
+ "fail",
65
+ "invalid",
66
+ "wrong",
67
+ "denied"
68
+ ];
69
+ var SUCCESS_WORDS = [
70
+ "success",
71
+ "saved",
72
+ "created",
73
+ "updated",
74
+ "complete"
75
+ ];
76
+ var WARNING_WORDS = [
77
+ "warning",
78
+ "caution",
79
+ "attention",
80
+ "note"
81
+ ];
82
+ var HOME_WORDS = [
83
+ "home",
84
+ "dashboard",
85
+ "main",
86
+ "start"
87
+ ];
88
+ var CLOSE_WORDS = [
89
+ "close",
90
+ "cancel",
91
+ "dismiss",
92
+ "x"
93
+ ];
94
+ var PLACEHOLDER_PATTERNS = [
95
+ "lorem ipsum",
96
+ "dolor sit amet",
97
+ "placeholder",
98
+ "sample text",
99
+ "text here",
100
+ "enter text",
101
+ "todo",
102
+ "tbd",
103
+ "xxx"
104
+ ];
105
+ var INPUT_TYPE_SUGGESTIONS = [
106
+ { keywords: ["email", "e-mail"], type: "email" },
107
+ { keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
108
+ { keywords: ["url", "website", "link"], type: "url" },
109
+ { keywords: ["password", "pwd"], type: "password" },
110
+ { keywords: ["search", "find", "query"], type: "search" },
111
+ { keywords: ["date", "birthday", "dob"], type: "date" },
112
+ { keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
113
+ ];
114
+ var FORM_INPUT_TYPES = [
115
+ "Input",
116
+ "Textarea",
117
+ "Select",
118
+ "Checkbox",
119
+ "Radio"
120
+ ];
121
+ var CONTAINER_TYPES = [
122
+ "Card",
123
+ "Section",
124
+ "Modal",
125
+ "Drawer",
126
+ "Main"
127
+ ];
128
+ var TEXT_CONTENT_TYPES = [
129
+ "Text",
130
+ "Title",
131
+ "Label"
132
+ ];
133
+
134
+ // src/utils.ts
135
+ function getNodeText(node) {
136
+ if ("content" in node && node.content) return String(node.content);
137
+ if ("text" in node && node.text) return String(node.text);
138
+ if ("label" in node && node.label) return String(node.label);
139
+ return "";
140
+ }
141
+ function getNodeTextLower(node) {
142
+ return getNodeText(node).toLowerCase();
143
+ }
144
+ function hasChildren(node) {
145
+ return "children" in node && Array.isArray(node.children);
146
+ }
147
+ function getChildren(node) {
148
+ if (hasChildren(node)) {
149
+ return node.children;
150
+ }
151
+ return [];
152
+ }
153
+ function findInChildren(node, predicate) {
154
+ const children = getChildren(node);
155
+ for (const child of children) {
156
+ if (predicate(child)) {
157
+ return child;
158
+ }
159
+ const found = findInChildren(child, predicate);
160
+ if (found) {
161
+ return found;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ function hasChildMatching(node, predicate) {
167
+ return findInChildren(node, predicate) !== null;
168
+ }
169
+ function countInChildren(node, predicate) {
170
+ const children = getChildren(node);
171
+ let count = 0;
172
+ for (const child of children) {
173
+ if (predicate(child)) {
174
+ count++;
175
+ }
176
+ count += countInChildren(child, predicate);
177
+ }
178
+ return count;
179
+ }
180
+ function filterChildren(node, predicate) {
181
+ const children = getChildren(node);
182
+ const result = [];
183
+ for (const child of children) {
184
+ if (predicate(child)) {
185
+ result.push(child);
186
+ }
187
+ result.push(...filterChildren(child, predicate));
188
+ }
189
+ return result;
190
+ }
191
+ function getSizeValue(node) {
192
+ if (!("size" in node)) return null;
193
+ const size = node.size;
194
+ if (typeof size === "number") return size;
195
+ if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
196
+ return null;
197
+ }
198
+ function containsAnyWord(text, words) {
199
+ const lowerText = text.toLowerCase();
200
+ return words.some((word) => lowerText.includes(word));
201
+ }
202
+ function matchesAnyWord(text, words) {
203
+ const lowerText = text.toLowerCase().trim();
204
+ return words.includes(lowerText);
205
+ }
206
+ function getNodeLocation(node) {
207
+ if ("loc" in node && node.loc && "start" in node.loc) {
208
+ return {
209
+ line: node.loc.start.line,
210
+ column: node.loc.start.column
211
+ };
212
+ }
213
+ return void 0;
214
+ }
215
+ function isNodeType(node, type) {
216
+ if (Array.isArray(type)) {
217
+ return type.includes(node.type);
218
+ }
219
+ return node.type === type;
220
+ }
221
+ function getButtonStyle(node) {
222
+ if ("primary" in node && node.primary) return "primary";
223
+ if ("secondary" in node && node.secondary) return "secondary";
224
+ if ("outline" in node && node.outline) return "outline";
225
+ if ("ghost" in node && node.ghost) return "ghost";
226
+ if ("danger" in node && node.danger) return "danger";
227
+ return "default";
228
+ }
229
+ function isAnyNode(value) {
230
+ return value !== null && typeof value === "object" && "type" in value && typeof value.type === "string";
231
+ }
232
+ function toAnyNodeArray(values) {
233
+ return values.filter(isAnyNode);
234
+ }
235
+ function getNodeItems(node) {
236
+ return "items" in node && Array.isArray(node.items) ? node.items : [];
237
+ }
238
+
1
239
  // src/rules/accessibility.ts
2
240
  var inputRequiresLabel = {
3
241
  id: "a11y-input-label",
@@ -19,7 +257,7 @@ var inputRequiresLabel = {
19
257
  suggestion: hasPlaceholder ? "Add a label attribute. Placeholder alone is not sufficient for accessibility" : "Add a label attribute to describe this field",
20
258
  path: context.path,
21
259
  nodeType: node.type,
22
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
260
+ location: getNodeLocation(node)
23
261
  };
24
262
  }
25
263
  return null;
@@ -44,7 +282,7 @@ var imageRequiresAlt = {
44
282
  suggestion: "Add an alt attribute describing the image content",
45
283
  path: context.path,
46
284
  nodeType: node.type,
47
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
285
+ location: getNodeLocation(node)
48
286
  };
49
287
  }
50
288
  return null;
@@ -59,7 +297,7 @@ var iconButtonRequiresLabel = {
59
297
  appliesTo: ["Button"],
60
298
  check: (node, context) => {
61
299
  const hasIcon = "icon" in node && node.icon;
62
- const hasContent = "content" in node && node.content && String(node.content).trim();
300
+ const hasContent = getNodeText(node).trim();
63
301
  if (hasIcon && !hasContent) {
64
302
  return {
65
303
  ruleId: "a11y-icon-button-label",
@@ -70,7 +308,7 @@ var iconButtonRequiresLabel = {
70
308
  suggestion: "Add text content or aria-label to describe the button action",
71
309
  path: context.path,
72
310
  nodeType: node.type,
73
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
311
+ location: getNodeLocation(node)
74
312
  };
75
313
  }
76
314
  return null;
@@ -84,9 +322,8 @@ var linkRequiresDescriptiveText = {
84
322
  description: "Links should have descriptive text that indicates where they lead",
85
323
  appliesTo: ["Link"],
86
324
  check: (node, context) => {
87
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
88
- const genericTexts = ["click here", "here", "read more", "more", "link"];
89
- if (genericTexts.includes(content.trim())) {
325
+ const content = getNodeText(node).toLowerCase();
326
+ if (GENERIC_LINK_TEXTS.includes(content.trim())) {
90
327
  return {
91
328
  ruleId: "a11y-link-text",
92
329
  category: "accessibility",
@@ -96,7 +333,7 @@ var linkRequiresDescriptiveText = {
96
333
  suggestion: `Replace "${content}" with descriptive text that indicates the link destination`,
97
334
  path: context.path,
98
335
  nodeType: node.type,
99
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
336
+ location: getNodeLocation(node)
100
337
  };
101
338
  }
102
339
  return null;
@@ -128,7 +365,7 @@ var headingHierarchy = {
128
365
  suggestion: `Use h${prevHeadingLevel + 1} instead of h${level}, or add intermediate headings`,
129
366
  path: context.path,
130
367
  nodeType: node.type,
131
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
368
+ location: getNodeLocation(node)
132
369
  };
133
370
  }
134
371
  return null;
@@ -151,30 +388,16 @@ var formRequiresSubmit = {
151
388
  description: "Forms with input fields should have a clear submit action",
152
389
  appliesTo: ["Card", "Section", "Modal", "Main"],
153
390
  check: (node, context) => {
154
- if (!("children" in node) || !Array.isArray(node.children)) {
391
+ if (!hasChildren(node)) {
155
392
  return null;
156
393
  }
157
- let hasInputs = false;
158
- let hasSubmitButton = false;
159
- function walkChildren(children) {
160
- for (const child of children) {
161
- if (["Input", "Textarea", "Select", "Checkbox", "Radio"].includes(child.type)) {
162
- hasInputs = true;
163
- }
164
- if (child.type === "Button") {
165
- const isPrimary = "primary" in child && child.primary;
166
- const content = "content" in child ? String(child.content || "").toLowerCase() : "";
167
- const submitWords = ["submit", "save", "send", "create", "add", "update", "confirm", "ok", "done"];
168
- if (isPrimary || submitWords.some((w) => content.includes(w))) {
169
- hasSubmitButton = true;
170
- }
171
- }
172
- if ("children" in child && Array.isArray(child.children)) {
173
- walkChildren(child.children);
174
- }
175
- }
176
- }
177
- walkChildren(node.children);
394
+ const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
395
+ const hasSubmitButton = hasChildMatching(node, (child) => {
396
+ if (child.type !== "Button") return false;
397
+ const isPrimary = "primary" in child && child.primary;
398
+ const content = getNodeText(child).toLowerCase();
399
+ return isPrimary || SUBMIT_WORDS.some((w) => content.includes(w));
400
+ });
178
401
  if (hasInputs && !hasSubmitButton) {
179
402
  return {
180
403
  ruleId: "form-submit-button",
@@ -185,7 +408,7 @@ var formRequiresSubmit = {
185
408
  suggestion: 'Add a primary button with a clear action label (e.g., "Submit", "Save")',
186
409
  path: context.path,
187
410
  nodeType: node.type,
188
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
411
+ location: getNodeLocation(node)
189
412
  };
190
413
  }
191
414
  return null;
@@ -211,7 +434,7 @@ var requiredFieldIndicator = {
211
434
  suggestion: 'Add an asterisk (*) to the label or include "required" in the label text',
212
435
  path: context.path,
213
436
  nodeType: node.type,
214
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
437
+ location: getNodeLocation(node)
215
438
  };
216
439
  }
217
440
  return null;
@@ -252,7 +475,7 @@ var passwordConfirmation = {
252
475
  suggestion: 'Add a "Confirm Password" field to prevent typos',
253
476
  path: context.path,
254
477
  nodeType: node.type,
255
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
478
+ location: getNodeLocation(node)
256
479
  };
257
480
  }
258
481
  return null;
@@ -270,16 +493,7 @@ var appropriateInputType = {
270
493
  const label = "label" in node ? String(node.label || "").toLowerCase() : "";
271
494
  const placeholder = "placeholder" in node ? String(node.placeholder || "").toLowerCase() : "";
272
495
  const combined = label + " " + placeholder;
273
- const suggestions = [
274
- { keywords: ["email", "e-mail"], type: "email" },
275
- { keywords: ["phone", "tel", "mobile", "cell"], type: "tel" },
276
- { keywords: ["url", "website", "link"], type: "url" },
277
- { keywords: ["password", "pwd"], type: "password" },
278
- { keywords: ["search", "find", "query"], type: "search" },
279
- { keywords: ["date", "birthday", "dob"], type: "date" },
280
- { keywords: ["number", "quantity", "amount", "count", "age"], type: "number" }
281
- ];
282
- for (const suggestion of suggestions) {
496
+ for (const suggestion of INPUT_TYPE_SUGGESTIONS) {
283
497
  if (suggestion.keywords.some((k) => combined.includes(k)) && inputType !== suggestion.type) {
284
498
  return {
285
499
  ruleId: "form-input-type",
@@ -290,7 +504,7 @@ var appropriateInputType = {
290
504
  suggestion: `Change inputType to "${suggestion.type}"`,
291
505
  path: context.path,
292
506
  nodeType: node.type,
293
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
507
+ location: getNodeLocation(node)
294
508
  };
295
509
  }
296
510
  }
@@ -305,21 +519,6 @@ var formRules = [
305
519
  ];
306
520
 
307
521
  // src/rules/touch-target.ts
308
- var MIN_TOUCH_TARGET = 44;
309
- var SIZE_MAP = {
310
- xs: 24,
311
- sm: 32,
312
- md: 40,
313
- lg: 48,
314
- xl: 56
315
- };
316
- function getSizeValue(node) {
317
- if (!("size" in node)) return null;
318
- const size = node.size;
319
- if (typeof size === "number") return size;
320
- if (typeof size === "string" && size in SIZE_MAP) return SIZE_MAP[size];
321
- return null;
322
- }
323
522
  var buttonTouchTarget = {
324
523
  id: "touch-button-size",
325
524
  category: "touch-target",
@@ -339,7 +538,7 @@ var buttonTouchTarget = {
339
538
  suggestion: `Use size="md" or larger for better touch accessibility`,
340
539
  path: context.path,
341
540
  nodeType: node.type,
342
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
541
+ location: getNodeLocation(node)
343
542
  };
344
543
  }
345
544
  return null;
@@ -368,7 +567,7 @@ var iconButtonTouchTarget = {
368
567
  suggestion: "Add padding (p=2 or more) or use a larger size",
369
568
  path: context.path,
370
569
  nodeType: node.type,
371
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
570
+ location: getNodeLocation(node)
372
571
  };
373
572
  }
374
573
  return null;
@@ -393,7 +592,7 @@ var checkboxRadioTouchTarget = {
393
592
  suggestion: "Add a label to increase the touch target area",
394
593
  path: context.path,
395
594
  nodeType: node.type,
396
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
595
+ location: getNodeLocation(node)
397
596
  };
398
597
  }
399
598
  return null;
@@ -422,7 +621,7 @@ var linkSpacing = {
422
621
  suggestion: "Add gap=2 or more to the parent row for better touch separation",
423
622
  path: context.path,
424
623
  nodeType: node.type,
425
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
624
+ location: getNodeLocation(node)
426
625
  };
427
626
  }
428
627
  }
@@ -449,7 +648,7 @@ var avatarTouchTarget = {
449
648
  suggestion: 'Use size="md" or larger for clickable avatars',
450
649
  path: context.path,
451
650
  nodeType: node.type,
452
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
651
+ location: getNodeLocation(node)
453
652
  };
454
653
  }
455
654
  return null;
@@ -464,18 +663,6 @@ var touchTargetRules = [
464
663
  ];
465
664
 
466
665
  // src/rules/consistency.ts
467
- var buttonStyleTracker = /* @__PURE__ */ new Map();
468
- function resetConsistencyTrackers() {
469
- buttonStyleTracker.clear();
470
- }
471
- function getButtonStyle(node) {
472
- if ("primary" in node && node.primary) return "primary";
473
- if ("secondary" in node && node.secondary) return "secondary";
474
- if ("outline" in node && node.outline) return "outline";
475
- if ("ghost" in node && node.ghost) return "ghost";
476
- if ("danger" in node && node.danger) return "danger";
477
- return "default";
478
- }
479
666
  var consistentButtonStyles = {
480
667
  id: "consistency-button-styles",
481
668
  category: "consistency",
@@ -484,10 +671,10 @@ var consistentButtonStyles = {
484
671
  description: "Action buttons in the same context should use consistent styling",
485
672
  appliesTo: ["Row", "Col", "Card", "Modal"],
486
673
  check: (node, context) => {
487
- if (!("children" in node) || !Array.isArray(node.children)) {
674
+ if (!hasChildren(node)) {
488
675
  return null;
489
676
  }
490
- const buttons = node.children.filter((c) => c.type === "Button");
677
+ const buttons = getChildren(node).filter((c) => c.type === "Button");
491
678
  if (buttons.length < 2) return null;
492
679
  const styles = buttons.map((b) => getButtonStyle(b));
493
680
  const uniqueStyles = [...new Set(styles)];
@@ -503,7 +690,7 @@ var consistentButtonStyles = {
503
690
  suggestion: "Use primary for main action, outline/ghost for secondary actions",
504
691
  path: context.path,
505
692
  nodeType: node.type,
506
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
693
+ location: getNodeLocation(node)
507
694
  };
508
695
  }
509
696
  return null;
@@ -531,7 +718,7 @@ var consistentSpacing = {
531
718
  suggestion: "Use the same gap value for sibling containers",
532
719
  path: context.path,
533
720
  nodeType: node.type,
534
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
721
+ location: getNodeLocation(node)
535
722
  };
536
723
  }
537
724
  return null;
@@ -571,7 +758,7 @@ var consistentCardStyling = {
571
758
  suggestion: "Apply the same visual treatment to sibling cards",
572
759
  path: context.path,
573
760
  nodeType: node.type,
574
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
761
+ location: getNodeLocation(node)
575
762
  };
576
763
  }
577
764
  return null;
@@ -585,12 +772,9 @@ var consistentAlertVariants = {
585
772
  description: "Alerts should use appropriate variants for their purpose",
586
773
  appliesTo: ["Alert"],
587
774
  check: (node, context) => {
588
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
775
+ const content = getNodeText(node).toLowerCase();
589
776
  const variant = "variant" in node ? String(node.variant || "") : "";
590
- const errorWords = ["error", "fail", "invalid", "wrong", "denied"];
591
- const successWords = ["success", "saved", "created", "updated", "complete"];
592
- const warningWords = ["warning", "caution", "attention", "note"];
593
- if (errorWords.some((w) => content.includes(w)) && variant !== "danger") {
777
+ if (ERROR_WORDS.some((w) => content.includes(w)) && variant !== "danger") {
594
778
  return {
595
779
  ruleId: "consistency-alert-variants",
596
780
  category: "consistency",
@@ -600,10 +784,10 @@ var consistentAlertVariants = {
600
784
  suggestion: "Add variant=danger to this error alert",
601
785
  path: context.path,
602
786
  nodeType: node.type,
603
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
787
+ location: getNodeLocation(node)
604
788
  };
605
789
  }
606
- if (successWords.some((w) => content.includes(w)) && variant !== "success") {
790
+ if (SUCCESS_WORDS.some((w) => content.includes(w)) && variant !== "success") {
607
791
  return {
608
792
  ruleId: "consistency-alert-variants",
609
793
  category: "consistency",
@@ -613,10 +797,10 @@ var consistentAlertVariants = {
613
797
  suggestion: "Add variant=success to this success alert",
614
798
  path: context.path,
615
799
  nodeType: node.type,
616
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
800
+ location: getNodeLocation(node)
617
801
  };
618
802
  }
619
- if (warningWords.some((w) => content.includes(w)) && variant !== "warning") {
803
+ if (WARNING_WORDS.some((w) => content.includes(w)) && variant !== "warning") {
620
804
  return {
621
805
  ruleId: "consistency-alert-variants",
622
806
  category: "consistency",
@@ -626,7 +810,7 @@ var consistentAlertVariants = {
626
810
  suggestion: "Add variant=warning to this warning alert",
627
811
  path: context.path,
628
812
  nodeType: node.type,
629
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
813
+ location: getNodeLocation(node)
630
814
  };
631
815
  }
632
816
  return null;
@@ -658,7 +842,7 @@ var noEmptyContainers = {
658
842
  suggestion: "Add content to this container or use a placeholder to indicate intended content",
659
843
  path: context.path,
660
844
  nodeType: node.type,
661
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
845
+ location: getNodeLocation(node)
662
846
  };
663
847
  }
664
848
  return null;
@@ -672,22 +856,13 @@ var clearCTA = {
672
856
  description: "Pages should have a clear primary action for users",
673
857
  appliesTo: ["Page"],
674
858
  check: (node, context) => {
675
- if (!("children" in node) || !Array.isArray(node.children)) {
859
+ if (!hasChildren(node)) {
676
860
  return null;
677
861
  }
678
- let hasPrimaryButton = false;
679
- function findPrimaryButton(children) {
680
- for (const child of children) {
681
- if (child.type === "Button" && "primary" in child && child.primary) {
682
- hasPrimaryButton = true;
683
- return;
684
- }
685
- if ("children" in child && Array.isArray(child.children)) {
686
- findPrimaryButton(child.children);
687
- }
688
- }
689
- }
690
- findPrimaryButton(node.children);
862
+ const hasPrimaryButton = hasChildMatching(
863
+ node,
864
+ (child) => child.type === "Button" && "primary" in child && !!child.primary
865
+ );
691
866
  if (!hasPrimaryButton) {
692
867
  return {
693
868
  ruleId: "usability-clear-cta",
@@ -698,7 +873,7 @@ var clearCTA = {
698
873
  suggestion: "Add a primary button for the main action on this page",
699
874
  path: context.path,
700
875
  nodeType: node.type,
701
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
876
+ location: getNodeLocation(node)
702
877
  };
703
878
  }
704
879
  return null;
@@ -712,11 +887,10 @@ var loadingStates = {
712
887
  description: "Actions that may take time should have loading indicators",
713
888
  appliesTo: ["Button"],
714
889
  check: (node, context) => {
715
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
890
+ const content = getNodeText(node).toLowerCase();
716
891
  const hasLoading = "loading" in node;
717
892
  const isPrimary = "primary" in node && node.primary;
718
- const asyncActions = ["submit", "save", "send", "upload", "download", "export", "import", "sync", "load"];
719
- if (isPrimary && asyncActions.some((a) => content.includes(a)) && !hasLoading) {
893
+ if (isPrimary && ASYNC_ACTION_WORDS.some((a) => content.includes(a)) && !hasLoading) {
720
894
  return {
721
895
  ruleId: "usability-loading-states",
722
896
  category: "usability",
@@ -726,7 +900,7 @@ var loadingStates = {
726
900
  suggestion: "Consider adding a loading variant for this button when action is in progress",
727
901
  path: context.path,
728
902
  nodeType: node.type,
729
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
903
+ location: getNodeLocation(node)
730
904
  };
731
905
  }
732
906
  return null;
@@ -740,10 +914,9 @@ var destructiveActionConfirmation = {
740
914
  description: "Destructive actions should have clear warning styling",
741
915
  appliesTo: ["Button"],
742
916
  check: (node, context) => {
743
- const content = "content" in node ? String(node.content || "").toLowerCase() : "";
917
+ const content = getNodeText(node).toLowerCase();
744
918
  const isDanger = "danger" in node && node.danger;
745
- const destructiveWords = ["delete", "remove", "destroy", "clear", "reset", "revoke", "terminate"];
746
- if (destructiveWords.some((w) => content.includes(w)) && !isDanger) {
919
+ if (DESTRUCTIVE_WORDS.some((w) => content.includes(w)) && !isDanger) {
747
920
  return {
748
921
  ruleId: "usability-destructive-confirm",
749
922
  category: "usability",
@@ -753,7 +926,7 @@ var destructiveActionConfirmation = {
753
926
  suggestion: "Add the danger attribute to this button",
754
927
  path: context.path,
755
928
  nodeType: node.type,
756
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
929
+ location: getNodeLocation(node)
757
930
  };
758
931
  }
759
932
  return null;
@@ -767,33 +940,21 @@ var modalCloseButton = {
767
940
  description: "Users should be able to close modals easily",
768
941
  appliesTo: ["Modal"],
769
942
  check: (node, context) => {
770
- if (!("children" in node) || !Array.isArray(node.children)) {
943
+ if (!hasChildren(node)) {
771
944
  return null;
772
945
  }
773
- let hasCloseButton = false;
774
- function findCloseButton(children) {
775
- for (const child of children) {
776
- if (child.type === "Button") {
777
- const content = "content" in child ? String(child.content || "").toLowerCase() : "";
778
- const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
779
- if (["close", "cancel", "dismiss", "x"].some((w) => content.includes(w) || icon.includes(w))) {
780
- hasCloseButton = true;
781
- return;
782
- }
783
- }
784
- if (child.type === "Icon") {
785
- const name = "name" in child ? String(child.name || "").toLowerCase() : "";
786
- if (name === "x" || name === "close") {
787
- hasCloseButton = true;
788
- return;
789
- }
790
- }
791
- if ("children" in child && Array.isArray(child.children)) {
792
- findCloseButton(child.children);
793
- }
946
+ const hasCloseButton = hasChildMatching(node, (child) => {
947
+ if (child.type === "Button") {
948
+ const content = getNodeText(child).toLowerCase();
949
+ const icon = "icon" in child ? String(child.icon || "").toLowerCase() : "";
950
+ return CLOSE_WORDS.some((w) => content.includes(w) || icon.includes(w));
794
951
  }
795
- }
796
- findCloseButton(node.children);
952
+ if (child.type === "Icon") {
953
+ const name = "name" in child ? String(child.name || "").toLowerCase() : "";
954
+ return name === "x" || name === "close";
955
+ }
956
+ return false;
957
+ });
797
958
  if (!hasCloseButton) {
798
959
  return {
799
960
  ruleId: "usability-modal-close",
@@ -804,7 +965,7 @@ var modalCloseButton = {
804
965
  suggestion: 'Add a close button (icon "x") or a "Cancel" button',
805
966
  path: context.path,
806
967
  nodeType: node.type,
807
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
968
+ location: getNodeLocation(node)
808
969
  };
809
970
  }
810
971
  return null;
@@ -818,18 +979,126 @@ var maxNestingDepth = {
818
979
  description: "Deeply nested layouts can be confusing and hard to maintain",
819
980
  appliesTo: ["Row", "Col", "Card", "Section"],
820
981
  check: (node, context) => {
821
- const MAX_DEPTH = 6;
822
- if (context.depth > MAX_DEPTH) {
982
+ if (context.depth > MAX_NESTING_DEPTH) {
823
983
  return {
824
984
  ruleId: "usability-nesting-depth",
825
985
  category: "usability",
826
986
  severity: "warning",
827
- message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_DEPTH})`,
987
+ message: `Component is nested ${context.depth} levels deep (max recommended: ${MAX_NESTING_DEPTH})`,
828
988
  description: "Excessive nesting makes layouts harder to understand and maintain",
829
989
  suggestion: "Consider flattening the layout or breaking into separate sections",
830
990
  path: context.path,
831
991
  nodeType: node.type,
832
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
992
+ location: getNodeLocation(node)
993
+ };
994
+ }
995
+ return null;
996
+ }
997
+ };
998
+ var tooManyButtons = {
999
+ id: "usability-too-many-buttons",
1000
+ category: "usability",
1001
+ severity: "warning",
1002
+ name: "Too many buttons in container",
1003
+ description: "Too many buttons can cause decision fatigue for users",
1004
+ appliesTo: ["Card", "Section", "Row", "Modal"],
1005
+ check: (node, context) => {
1006
+ if (!hasChildren(node)) {
1007
+ return null;
1008
+ }
1009
+ const buttonCount = getChildren(node).filter((c) => c.type === "Button").length;
1010
+ if (buttonCount > MAX_BUTTONS) {
1011
+ return {
1012
+ ruleId: "usability-too-many-buttons",
1013
+ category: "usability",
1014
+ severity: "warning",
1015
+ message: `Container has ${buttonCount} buttons (recommended max: ${MAX_BUTTONS})`,
1016
+ description: "Too many choices can overwhelm users and slow decision-making",
1017
+ suggestion: "Consider grouping actions in a dropdown or prioritizing the most important actions",
1018
+ path: context.path,
1019
+ nodeType: node.type,
1020
+ location: getNodeLocation(node)
1021
+ };
1022
+ }
1023
+ return null;
1024
+ }
1025
+ };
1026
+ var tooManyFormFields = {
1027
+ id: "usability-too-many-form-fields",
1028
+ category: "usability",
1029
+ severity: "info",
1030
+ name: "Too many form fields",
1031
+ description: "Forms with many fields have higher abandonment rates",
1032
+ appliesTo: ["Card", "Section", "Main", "Modal"],
1033
+ check: (node, context) => {
1034
+ if (!hasChildren(node)) {
1035
+ return null;
1036
+ }
1037
+ const formFieldCount = countInChildren(node, (child) => FORM_INPUT_TYPES.includes(child.type));
1038
+ if (formFieldCount > MAX_FORM_FIELDS) {
1039
+ return {
1040
+ ruleId: "usability-too-many-form-fields",
1041
+ category: "usability",
1042
+ severity: "info",
1043
+ message: `Form area has ${formFieldCount} fields (recommended max: ${MAX_FORM_FIELDS})`,
1044
+ description: "Long forms increase cognitive load and abandonment rates",
1045
+ suggestion: "Consider breaking into multiple steps, using progressive disclosure, or removing optional fields",
1046
+ path: context.path,
1047
+ nodeType: node.type,
1048
+ location: getNodeLocation(node)
1049
+ };
1050
+ }
1051
+ return null;
1052
+ }
1053
+ };
1054
+ var tooManyPageElements = {
1055
+ id: "usability-page-complexity",
1056
+ category: "usability",
1057
+ severity: "info",
1058
+ name: "Page may be too complex",
1059
+ description: "Pages with too many elements can overwhelm users",
1060
+ appliesTo: ["Page"],
1061
+ check: (node, context) => {
1062
+ if (!hasChildren(node)) {
1063
+ return null;
1064
+ }
1065
+ const elementCount = countInChildren(node, () => true);
1066
+ if (elementCount > MAX_PAGE_ELEMENTS) {
1067
+ return {
1068
+ ruleId: "usability-page-complexity",
1069
+ category: "usability",
1070
+ severity: "info",
1071
+ message: `Page has ${elementCount} elements (consider if this complexity is necessary)`,
1072
+ description: "Complex pages can be overwhelming and slow to render",
1073
+ suggestion: "Consider splitting into multiple pages, using tabs, or simplifying the layout",
1074
+ path: context.path,
1075
+ nodeType: node.type,
1076
+ location: getNodeLocation(node)
1077
+ };
1078
+ }
1079
+ return null;
1080
+ }
1081
+ };
1082
+ var drawerWidth = {
1083
+ id: "usability-drawer-width",
1084
+ category: "usability",
1085
+ severity: "info",
1086
+ name: "Drawer should have appropriate width",
1087
+ description: "Drawers should have a defined width for consistent UX",
1088
+ appliesTo: ["Drawer"],
1089
+ check: (node, context) => {
1090
+ const hasWidth = "width" in node || "w" in node;
1091
+ if (!hasWidth) {
1092
+ return {
1093
+ ruleId: "usability-drawer-width",
1094
+ category: "usability",
1095
+ severity: "info",
1096
+ message: "Drawer has no width specified",
1097
+ description: "Drawers without explicit width may render inconsistently across devices",
1098
+ suggestion: 'Add a width attribute (e.g., width="320" or w="80")',
1099
+ path: context.path,
1100
+ nodeType: node.type,
1101
+ location: getNodeLocation(node)
833
1102
  };
834
1103
  }
835
1104
  return null;
@@ -841,7 +1110,11 @@ var usabilityRules = [
841
1110
  loadingStates,
842
1111
  destructiveActionConfirmation,
843
1112
  modalCloseButton,
844
- maxNestingDepth
1113
+ maxNestingDepth,
1114
+ tooManyButtons,
1115
+ tooManyFormFields,
1116
+ tooManyPageElements,
1117
+ drawerWidth
845
1118
  ];
846
1119
 
847
1120
  // src/rules/navigation.ts
@@ -853,8 +1126,7 @@ var navItemCount = {
853
1126
  description: "Navigation menus with too many items can overwhelm users",
854
1127
  appliesTo: ["Nav"],
855
1128
  check: (node, context) => {
856
- const MAX_NAV_ITEMS = 7;
857
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1129
+ const items = getNodeItems(node);
858
1130
  if (items.length > MAX_NAV_ITEMS) {
859
1131
  return {
860
1132
  ruleId: "nav-item-count",
@@ -865,7 +1137,7 @@ var navItemCount = {
865
1137
  suggestion: "Group related items into categories or use a hierarchical navigation",
866
1138
  path: context.path,
867
1139
  nodeType: node.type,
868
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1140
+ location: getNodeLocation(node)
869
1141
  };
870
1142
  }
871
1143
  return null;
@@ -879,7 +1151,7 @@ var navActiveState = {
879
1151
  description: "Users should know which page they are currently on",
880
1152
  appliesTo: ["Nav"],
881
1153
  check: (node, context) => {
882
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1154
+ const items = getNodeItems(node);
883
1155
  if (items.length === 0) return null;
884
1156
  const hasActiveItem = items.some((item) => {
885
1157
  if (typeof item === "object" && item !== null) {
@@ -897,7 +1169,7 @@ var navActiveState = {
897
1169
  suggestion: "Add active attribute to the current navigation item",
898
1170
  path: context.path,
899
1171
  nodeType: node.type,
900
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1172
+ location: getNodeLocation(node)
901
1173
  };
902
1174
  }
903
1175
  return null;
@@ -911,12 +1183,11 @@ var breadcrumbHasHome = {
911
1183
  description: "Breadcrumbs typically start with a home or root link",
912
1184
  appliesTo: ["Breadcrumb"],
913
1185
  check: (node, context) => {
914
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
1186
+ const items = getNodeItems(node);
915
1187
  if (items.length === 0) return null;
916
1188
  const firstItem = items[0];
917
1189
  const firstLabel = typeof firstItem === "string" ? firstItem.toLowerCase() : firstItem?.label?.toLowerCase() || "";
918
- const homeWords = ["home", "dashboard", "main", "start"];
919
- if (!homeWords.some((w) => firstLabel.includes(w))) {
1190
+ if (!HOME_WORDS.some((w) => firstLabel.includes(w))) {
920
1191
  return {
921
1192
  ruleId: "nav-breadcrumb-home",
922
1193
  category: "navigation",
@@ -926,7 +1197,7 @@ var breadcrumbHasHome = {
926
1197
  suggestion: 'Add "Home" or equivalent as the first breadcrumb item',
927
1198
  path: context.path,
928
1199
  nodeType: node.type,
929
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1200
+ location: getNodeLocation(node)
930
1201
  };
931
1202
  }
932
1203
  return null;
@@ -940,15 +1211,9 @@ var tabCount = {
940
1211
  description: "Too many tabs can be overwhelming",
941
1212
  appliesTo: ["Tabs"],
942
1213
  check: (node, context) => {
943
- const MAX_TABS = 5;
944
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
945
- const children = "children" in node && Array.isArray(node.children) ? node.children : [];
946
- const childTabCount = children.filter((c) => {
947
- if (typeof c === "object" && c !== null && "type" in c) {
948
- return String(c.type).toLowerCase().includes("tab");
949
- }
950
- return false;
951
- }).length;
1214
+ const items = getNodeItems(node);
1215
+ const children = hasChildren(node) ? getChildren(node) : [];
1216
+ const childTabCount = children.filter((c) => c.type.toLowerCase().includes("tab")).length;
952
1217
  const tabCount2 = items.length || childTabCount;
953
1218
  if (tabCount2 > MAX_TABS) {
954
1219
  return {
@@ -960,7 +1225,7 @@ var tabCount = {
960
1225
  suggestion: "Consider using a different navigation pattern or grouping related content",
961
1226
  path: context.path,
962
1227
  nodeType: node.type,
963
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1228
+ location: getNodeLocation(node)
964
1229
  };
965
1230
  }
966
1231
  return null;
@@ -974,8 +1239,8 @@ var dropdownHasItems = {
974
1239
  description: "Dropdown menus need items to be functional",
975
1240
  appliesTo: ["Dropdown"],
976
1241
  check: (node, context) => {
977
- const items = "items" in node && Array.isArray(node.items) ? node.items : [];
978
- const children = "children" in node && Array.isArray(node.children) ? node.children : [];
1242
+ const items = getNodeItems(node);
1243
+ const children = hasChildren(node) ? getChildren(node) : [];
979
1244
  if (items.length === 0 && children.length === 0) {
980
1245
  return {
981
1246
  ruleId: "nav-dropdown-items",
@@ -986,7 +1251,7 @@ var dropdownHasItems = {
986
1251
  suggestion: "Add items to the dropdown menu",
987
1252
  path: context.path,
988
1253
  nodeType: node.type,
989
- location: node.loc ? { line: node.loc.start.line, column: node.loc.start.column } : void 0
1254
+ location: getNodeLocation(node)
990
1255
  };
991
1256
  }
992
1257
  return null;
@@ -1000,6 +1265,819 @@ var navigationRules = [
1000
1265
  dropdownHasItems
1001
1266
  ];
1002
1267
 
1268
+ // src/rules/content.ts
1269
+ var emptyTextContent = {
1270
+ id: "content-empty-text",
1271
+ category: "content",
1272
+ severity: "warning",
1273
+ name: "Avoid empty text content",
1274
+ description: "Text elements should have meaningful content",
1275
+ appliesTo: ["Text", "Title", "Label"],
1276
+ check: (node, context) => {
1277
+ const trimmed = getNodeText(node).trim();
1278
+ if (trimmed === "" || trimmed === "..." || trimmed === "Lorem ipsum") {
1279
+ return {
1280
+ ruleId: "content-empty-text",
1281
+ category: "content",
1282
+ severity: "warning",
1283
+ message: `${node.type} has placeholder or empty content`,
1284
+ description: "Placeholder text should be replaced with meaningful content",
1285
+ suggestion: "Replace with actual content or remove if not needed",
1286
+ path: context.path,
1287
+ nodeType: node.type,
1288
+ location: getNodeLocation(node)
1289
+ };
1290
+ }
1291
+ return null;
1292
+ }
1293
+ };
1294
+ var buttonTextLength = {
1295
+ id: "content-button-text-length",
1296
+ category: "content",
1297
+ severity: "info",
1298
+ name: "Button text should be concise",
1299
+ description: "Button labels should be short and action-oriented",
1300
+ appliesTo: ["Button"],
1301
+ check: (node, context) => {
1302
+ const content = getNodeText(node);
1303
+ if (content.length > MAX_BUTTON_TEXT_LENGTH) {
1304
+ return {
1305
+ ruleId: "content-button-text-length",
1306
+ category: "content",
1307
+ severity: "info",
1308
+ message: `Button text is ${content.length} characters (recommended max: ${MAX_BUTTON_TEXT_LENGTH})`,
1309
+ description: "Long button text can be hard to read and may not fit on smaller screens",
1310
+ suggestion: 'Use concise, action-oriented text (e.g., "Save" instead of "Click here to save your changes")',
1311
+ path: context.path,
1312
+ nodeType: node.type,
1313
+ location: getNodeLocation(node)
1314
+ };
1315
+ }
1316
+ return null;
1317
+ }
1318
+ };
1319
+ var titleLength = {
1320
+ id: "content-title-length",
1321
+ category: "content",
1322
+ severity: "info",
1323
+ name: "Title should be concise",
1324
+ description: "Titles should be short and descriptive",
1325
+ appliesTo: ["Title"],
1326
+ check: (node, context) => {
1327
+ const content = getNodeText(node);
1328
+ if (content.length > MAX_TITLE_LENGTH) {
1329
+ return {
1330
+ ruleId: "content-title-length",
1331
+ category: "content",
1332
+ severity: "info",
1333
+ message: `Title is ${content.length} characters (recommended max: ${MAX_TITLE_LENGTH})`,
1334
+ description: "Long titles can be hard to scan and may get truncated on smaller screens",
1335
+ suggestion: "Shorten the title and move details to a subtitle or description",
1336
+ path: context.path,
1337
+ nodeType: node.type,
1338
+ location: getNodeLocation(node)
1339
+ };
1340
+ }
1341
+ return null;
1342
+ }
1343
+ };
1344
+ var pageHasTitle = {
1345
+ id: "content-page-title",
1346
+ category: "content",
1347
+ severity: "warning",
1348
+ name: "Page should have a title",
1349
+ description: "Every page should have a clear title to orient users",
1350
+ appliesTo: ["Page"],
1351
+ check: (node, context) => {
1352
+ if (!hasChildren(node)) {
1353
+ return null;
1354
+ }
1355
+ const hasTitleElement = hasChildMatching(node, (child) => child.type === "Title");
1356
+ if (!hasTitleElement) {
1357
+ return {
1358
+ ruleId: "content-page-title",
1359
+ category: "content",
1360
+ severity: "warning",
1361
+ message: "Page has no title",
1362
+ description: "Users need a clear title to understand the page purpose",
1363
+ suggestion: "Add a Title component to identify the page",
1364
+ path: context.path,
1365
+ nodeType: node.type,
1366
+ location: getNodeLocation(node)
1367
+ };
1368
+ }
1369
+ return null;
1370
+ }
1371
+ };
1372
+ var linkHasText = {
1373
+ id: "content-link-text",
1374
+ category: "content",
1375
+ severity: "error",
1376
+ name: "Link should have text",
1377
+ description: "Links must have visible text for users to understand where they lead",
1378
+ appliesTo: ["Link"],
1379
+ check: (node, context) => {
1380
+ const content = getNodeText(node).trim();
1381
+ const hasChildElements = hasChildren(node) && getChildren(node).length > 0;
1382
+ if (!content && !hasChildElements) {
1383
+ return {
1384
+ ruleId: "content-link-text",
1385
+ category: "content",
1386
+ severity: "error",
1387
+ message: "Link has no visible text or content",
1388
+ description: "Users cannot understand or interact with links that have no text",
1389
+ suggestion: "Add descriptive text to the link",
1390
+ path: context.path,
1391
+ nodeType: node.type,
1392
+ location: getNodeLocation(node)
1393
+ };
1394
+ }
1395
+ return null;
1396
+ }
1397
+ };
1398
+ var noPlaceholderContent = {
1399
+ id: "content-no-placeholder",
1400
+ category: "content",
1401
+ severity: "warning",
1402
+ name: "Avoid placeholder content",
1403
+ description: 'Placeholder text like "Lorem ipsum" should be replaced',
1404
+ appliesTo: ["Text", "Title", "Label", "Button"],
1405
+ check: (node, context) => {
1406
+ const content = getNodeText(node).toLowerCase();
1407
+ for (const placeholder of PLACEHOLDER_PATTERNS) {
1408
+ if (content.includes(placeholder)) {
1409
+ return {
1410
+ ruleId: "content-no-placeholder",
1411
+ category: "content",
1412
+ severity: "warning",
1413
+ message: `${node.type} contains placeholder text "${placeholder}"`,
1414
+ description: "Placeholder text should be replaced before production",
1415
+ suggestion: "Replace with actual content",
1416
+ path: context.path,
1417
+ nodeType: node.type,
1418
+ location: getNodeLocation(node)
1419
+ };
1420
+ }
1421
+ }
1422
+ return null;
1423
+ }
1424
+ };
1425
+ var contentRules = [
1426
+ emptyTextContent,
1427
+ buttonTextLength,
1428
+ titleLength,
1429
+ pageHasTitle,
1430
+ linkHasText,
1431
+ noPlaceholderContent
1432
+ ];
1433
+
1434
+ // src/rules/data-display.ts
1435
+ var tableHasHeader = {
1436
+ id: "data-table-header",
1437
+ category: "data-display",
1438
+ severity: "warning",
1439
+ name: "Table should have header",
1440
+ description: "Tables should have a header row to identify columns",
1441
+ appliesTo: ["Table"],
1442
+ check: (node, context) => {
1443
+ const hasHeader = "header" in node && node.header;
1444
+ const hasColumns = "columns" in node && Array.isArray(node.columns) && node.columns.length > 0;
1445
+ if (!hasHeader && !hasColumns) {
1446
+ return {
1447
+ ruleId: "data-table-header",
1448
+ category: "data-display",
1449
+ severity: "warning",
1450
+ message: "Table has no header or columns defined",
1451
+ description: "Table headers help users understand what each column represents",
1452
+ suggestion: "Add a header attribute or define columns for the table",
1453
+ path: context.path,
1454
+ nodeType: node.type,
1455
+ location: getNodeLocation(node)
1456
+ };
1457
+ }
1458
+ return null;
1459
+ }
1460
+ };
1461
+ var listEmptyState = {
1462
+ id: "data-list-empty-state",
1463
+ category: "data-display",
1464
+ severity: "info",
1465
+ name: "Consider empty state for list",
1466
+ description: "Lists should indicate when they have no items",
1467
+ appliesTo: ["List"],
1468
+ check: (node, context) => {
1469
+ const items = getNodeItems(node);
1470
+ const children = hasChildren(node) ? getChildren(node) : [];
1471
+ const hasEmptyStateAttr = "emptyState" in node || "empty" in node;
1472
+ if (items.length > 0 || children.length > 0) {
1473
+ return null;
1474
+ }
1475
+ if (!hasEmptyStateAttr) {
1476
+ return {
1477
+ ruleId: "data-list-empty-state",
1478
+ category: "data-display",
1479
+ severity: "info",
1480
+ message: "Empty list has no empty state defined",
1481
+ description: "Users should see a helpful message when lists are empty",
1482
+ suggestion: "Add an emptyState attribute or component to show when list is empty",
1483
+ path: context.path,
1484
+ nodeType: node.type,
1485
+ location: getNodeLocation(node)
1486
+ };
1487
+ }
1488
+ return null;
1489
+ }
1490
+ };
1491
+ var tableEmptyState = {
1492
+ id: "data-table-empty-state",
1493
+ category: "data-display",
1494
+ severity: "info",
1495
+ name: "Consider empty state for table",
1496
+ description: "Tables should indicate when they have no data",
1497
+ appliesTo: ["Table"],
1498
+ check: (node, context) => {
1499
+ const rows = "rows" in node && Array.isArray(node.rows) ? node.rows : [];
1500
+ const data = "data" in node && Array.isArray(node.data) ? node.data : [];
1501
+ const hasEmptyState = "emptyState" in node || "empty" in node;
1502
+ if (rows.length > 0 || data.length > 0) {
1503
+ return null;
1504
+ }
1505
+ if (!hasEmptyState) {
1506
+ return {
1507
+ ruleId: "data-table-empty-state",
1508
+ category: "data-display",
1509
+ severity: "info",
1510
+ message: "Empty table has no empty state defined",
1511
+ description: "Users should see a helpful message when tables have no data",
1512
+ suggestion: "Add an emptyState attribute to show when table is empty",
1513
+ path: context.path,
1514
+ nodeType: node.type,
1515
+ location: getNodeLocation(node)
1516
+ };
1517
+ }
1518
+ return null;
1519
+ }
1520
+ };
1521
+ var listTooManyItems = {
1522
+ id: "data-list-pagination",
1523
+ category: "data-display",
1524
+ severity: "info",
1525
+ name: "Long list may need pagination",
1526
+ description: "Lists with many items should consider pagination or virtualization",
1527
+ appliesTo: ["List"],
1528
+ check: (node, context) => {
1529
+ const items = getNodeItems(node);
1530
+ const children = hasChildren(node) ? getChildren(node) : [];
1531
+ const hasPagination = "pagination" in node || "paginated" in node;
1532
+ const itemCount = items.length || children.filter((c) => isNodeType(c, "ListItem")).length;
1533
+ if (itemCount > MAX_LIST_ITEMS && !hasPagination) {
1534
+ return {
1535
+ ruleId: "data-list-pagination",
1536
+ category: "data-display",
1537
+ severity: "info",
1538
+ message: `List has ${itemCount} items (consider pagination for better performance)`,
1539
+ description: "Long lists can be slow to render and overwhelming for users",
1540
+ suggestion: "Add pagination, infinite scroll, or virtualization for long lists",
1541
+ path: context.path,
1542
+ nodeType: node.type,
1543
+ location: getNodeLocation(node)
1544
+ };
1545
+ }
1546
+ return null;
1547
+ }
1548
+ };
1549
+ var tableTooManyColumns = {
1550
+ id: "data-table-columns",
1551
+ category: "data-display",
1552
+ severity: "warning",
1553
+ name: "Table has too many columns",
1554
+ description: "Tables with many columns are hard to read on smaller screens",
1555
+ appliesTo: ["Table"],
1556
+ check: (node, context) => {
1557
+ const columns = "columns" in node && Array.isArray(node.columns) ? node.columns : [];
1558
+ if (columns.length > MAX_TABLE_COLUMNS) {
1559
+ return {
1560
+ ruleId: "data-table-columns",
1561
+ category: "data-display",
1562
+ severity: "warning",
1563
+ message: `Table has ${columns.length} columns (recommended max: ${MAX_TABLE_COLUMNS})`,
1564
+ description: "Tables with many columns are difficult to read and may not fit on mobile",
1565
+ suggestion: "Consider hiding less important columns on mobile or using a different layout",
1566
+ path: context.path,
1567
+ nodeType: node.type,
1568
+ location: getNodeLocation(node)
1569
+ };
1570
+ }
1571
+ return null;
1572
+ }
1573
+ };
1574
+ var cardGridConsistency = {
1575
+ id: "data-card-grid",
1576
+ category: "data-display",
1577
+ severity: "info",
1578
+ name: "Card grid should have consistent items",
1579
+ description: "Cards in a grid layout should have consistent dimensions",
1580
+ appliesTo: ["Row"],
1581
+ check: (node, context) => {
1582
+ if (!hasChildren(node)) {
1583
+ return null;
1584
+ }
1585
+ const cards = getChildren(node).filter((c) => c.type === "Card");
1586
+ if (cards.length < 3) return null;
1587
+ const heights = cards.map((c) => "height" in c ? c.height : "h" in c ? c.h : null);
1588
+ const definedHeights = heights.filter((h) => h !== null);
1589
+ const uniqueHeights = [...new Set(definedHeights)];
1590
+ if (uniqueHeights.length > 1) {
1591
+ return {
1592
+ ruleId: "data-card-grid",
1593
+ category: "data-display",
1594
+ severity: "info",
1595
+ message: "Card grid has inconsistent card heights",
1596
+ description: "Inconsistent card sizes can create visual imbalance",
1597
+ suggestion: "Consider using consistent heights for cards in a grid layout",
1598
+ path: context.path,
1599
+ nodeType: node.type,
1600
+ location: getNodeLocation(node)
1601
+ };
1602
+ }
1603
+ return null;
1604
+ }
1605
+ };
1606
+ var dataDisplayRules = [
1607
+ tableHasHeader,
1608
+ listEmptyState,
1609
+ tableEmptyState,
1610
+ listTooManyItems,
1611
+ tableTooManyColumns,
1612
+ cardGridConsistency
1613
+ ];
1614
+
1615
+ // src/rules/feedback.ts
1616
+ var spinnerHasContext = {
1617
+ id: "feedback-spinner-context",
1618
+ category: "feedback",
1619
+ severity: "info",
1620
+ name: "Spinner should have context",
1621
+ description: "Loading spinners should indicate what is being loaded",
1622
+ appliesTo: ["Spinner"],
1623
+ check: (node, context) => {
1624
+ const hasText = "text" in node && node.text;
1625
+ const hasLabel = "label" in node && node.label;
1626
+ const hasContent = "content" in node && node.content;
1627
+ const siblingText = context.siblings.some(
1628
+ (s) => s.type === "Text" && "content" in s && String(s.content || "").toLowerCase().includes("loading")
1629
+ );
1630
+ if (!hasText && !hasLabel && !hasContent && !siblingText) {
1631
+ return {
1632
+ ruleId: "feedback-spinner-context",
1633
+ category: "feedback",
1634
+ severity: "info",
1635
+ message: "Spinner has no loading text",
1636
+ description: "Users benefit from knowing what is being loaded",
1637
+ suggestion: 'Add a text/label attribute like "Loading..." or "Please wait..."',
1638
+ path: context.path,
1639
+ nodeType: node.type,
1640
+ location: getNodeLocation(node)
1641
+ };
1642
+ }
1643
+ return null;
1644
+ }
1645
+ };
1646
+ var progressHasValue = {
1647
+ id: "feedback-progress-value",
1648
+ category: "feedback",
1649
+ severity: "info",
1650
+ name: "Progress should show value",
1651
+ description: "Progress bars should indicate completion percentage",
1652
+ appliesTo: ["Progress"],
1653
+ check: (node, context) => {
1654
+ const hasValue = "value" in node;
1655
+ const hasPercent = "percent" in node;
1656
+ const isIndeterminate = "indeterminate" in node && node.indeterminate;
1657
+ if (isIndeterminate) return null;
1658
+ if (!hasValue && !hasPercent) {
1659
+ return {
1660
+ ruleId: "feedback-progress-value",
1661
+ category: "feedback",
1662
+ severity: "info",
1663
+ message: "Progress bar has no value specified",
1664
+ description: "Users should see how much progress has been made",
1665
+ suggestion: "Add a value attribute (0-100) or use indeterminate for unknown duration",
1666
+ path: context.path,
1667
+ nodeType: node.type,
1668
+ location: getNodeLocation(node)
1669
+ };
1670
+ }
1671
+ return null;
1672
+ }
1673
+ };
1674
+ var toastDuration = {
1675
+ id: "feedback-toast-duration",
1676
+ category: "feedback",
1677
+ severity: "info",
1678
+ name: "Toast should have appropriate duration",
1679
+ description: "Toasts should auto-dismiss after a reasonable time",
1680
+ appliesTo: ["Toast"],
1681
+ check: (node, context) => {
1682
+ const duration = "duration" in node ? Number(node.duration) : null;
1683
+ if (duration !== null) {
1684
+ if (duration < MIN_TOAST_DURATION) {
1685
+ return {
1686
+ ruleId: "feedback-toast-duration",
1687
+ category: "feedback",
1688
+ severity: "info",
1689
+ message: `Toast duration (${duration}ms) may be too short to read`,
1690
+ description: "Users need time to read toast messages",
1691
+ suggestion: `Increase duration to at least ${MIN_TOAST_DURATION}ms`,
1692
+ path: context.path,
1693
+ nodeType: node.type,
1694
+ location: getNodeLocation(node)
1695
+ };
1696
+ }
1697
+ if (duration > MAX_TOAST_DURATION) {
1698
+ return {
1699
+ ruleId: "feedback-toast-duration",
1700
+ category: "feedback",
1701
+ severity: "info",
1702
+ message: `Toast duration (${duration}ms) may be too long`,
1703
+ description: "Long-lasting toasts can be annoying and block UI",
1704
+ suggestion: `Consider reducing duration or using a persistent alert instead`,
1705
+ path: context.path,
1706
+ nodeType: node.type,
1707
+ location: getNodeLocation(node)
1708
+ };
1709
+ }
1710
+ }
1711
+ return null;
1712
+ }
1713
+ };
1714
+ var alertDismissible = {
1715
+ id: "feedback-alert-dismissible",
1716
+ category: "feedback",
1717
+ severity: "info",
1718
+ name: "Non-critical alerts should be dismissible",
1719
+ description: "Info and success alerts should be dismissible by users",
1720
+ appliesTo: ["Alert"],
1721
+ check: (node, context) => {
1722
+ const variant = "variant" in node ? String(node.variant || "") : "";
1723
+ const isDismissible = "dismissible" in node || "closable" in node || "onClose" in node;
1724
+ if (variant === "danger" || variant === "error") return null;
1725
+ if ((variant === "info" || variant === "success") && !isDismissible) {
1726
+ return {
1727
+ ruleId: "feedback-alert-dismissible",
1728
+ category: "feedback",
1729
+ severity: "info",
1730
+ message: `${variant} alert is not dismissible`,
1731
+ description: "Users should be able to dismiss non-critical alerts",
1732
+ suggestion: "Add dismissible or closable attribute to allow users to close the alert",
1733
+ path: context.path,
1734
+ nodeType: node.type,
1735
+ location: getNodeLocation(node)
1736
+ };
1737
+ }
1738
+ return null;
1739
+ }
1740
+ };
1741
+ var tooltipContentLength = {
1742
+ id: "feedback-tooltip-length",
1743
+ category: "feedback",
1744
+ severity: "info",
1745
+ name: "Tooltip content should be brief",
1746
+ description: "Tooltips should be short and helpful",
1747
+ appliesTo: ["Tooltip"],
1748
+ check: (node, context) => {
1749
+ const tooltipText = getNodeText(node);
1750
+ if (tooltipText.length > MAX_TOOLTIP_LENGTH) {
1751
+ return {
1752
+ ruleId: "feedback-tooltip-length",
1753
+ category: "feedback",
1754
+ severity: "info",
1755
+ message: `Tooltip is ${tooltipText.length} characters (recommended max: ${MAX_TOOLTIP_LENGTH})`,
1756
+ description: "Long tooltips are hard to read and may disappear before being fully read",
1757
+ suggestion: "Keep tooltips brief or use a popover for longer content",
1758
+ path: context.path,
1759
+ nodeType: node.type,
1760
+ location: getNodeLocation(node)
1761
+ };
1762
+ }
1763
+ return null;
1764
+ }
1765
+ };
1766
+ var formErrorFeedback = {
1767
+ id: "feedback-form-errors",
1768
+ category: "feedback",
1769
+ severity: "info",
1770
+ name: "Form should handle errors",
1771
+ description: "Forms should have a way to display validation errors",
1772
+ appliesTo: ["Card", "Section", "Modal"],
1773
+ check: (node, context) => {
1774
+ if (!hasChildren(node)) {
1775
+ return null;
1776
+ }
1777
+ const hasInputs = hasChildMatching(node, (child) => FORM_INPUT_TYPES.includes(child.type));
1778
+ const hasErrorDisplay = hasChildMatching(node, (child) => {
1779
+ if (FORM_INPUT_TYPES.includes(child.type)) {
1780
+ if ("error" in child || "errorText" in child || "helperText" in child) {
1781
+ return true;
1782
+ }
1783
+ }
1784
+ if (child.type === "Alert" && "variant" in child) {
1785
+ const variant = String(child.variant || "");
1786
+ if (variant === "error" || variant === "danger") {
1787
+ return true;
1788
+ }
1789
+ }
1790
+ if (child.type === "Text") {
1791
+ const content = getNodeText(child).toLowerCase();
1792
+ if (content.includes("error") || content.includes("invalid")) {
1793
+ return true;
1794
+ }
1795
+ }
1796
+ return false;
1797
+ });
1798
+ if (hasInputs && !hasErrorDisplay) {
1799
+ return {
1800
+ ruleId: "feedback-form-errors",
1801
+ category: "feedback",
1802
+ severity: "info",
1803
+ message: "Form area has no visible error handling",
1804
+ description: "Users need to see validation errors when they occur",
1805
+ suggestion: "Add error/errorText attributes to inputs or include an Alert for form-level errors",
1806
+ path: context.path,
1807
+ nodeType: node.type,
1808
+ location: getNodeLocation(node)
1809
+ };
1810
+ }
1811
+ return null;
1812
+ }
1813
+ };
1814
+ var feedbackRules = [
1815
+ spinnerHasContext,
1816
+ progressHasValue,
1817
+ toastDuration,
1818
+ alertDismissible,
1819
+ tooltipContentLength,
1820
+ formErrorFeedback
1821
+ ];
1822
+
1823
+ // src/rules/interaction.ts
1824
+ var buttonHasAction = {
1825
+ id: "interaction-button-action",
1826
+ category: "interaction",
1827
+ severity: "warning",
1828
+ name: "Button should have action",
1829
+ description: "Buttons should have an onClick, action, or navigation target defined",
1830
+ appliesTo: ["Button"],
1831
+ check: (node, context) => {
1832
+ const hasOnClick = "onClick" in node;
1833
+ const hasAction = "action" in node;
1834
+ const hasHref = "href" in node;
1835
+ const hasTo = "to" in node;
1836
+ const hasNavigate = "navigate" in node;
1837
+ const hasLink = "link" in node;
1838
+ const hasSubmit = "buttonType" in node && node.buttonType === "submit";
1839
+ const hasFormSubmit = "submit" in node;
1840
+ const buttonText = getNodeText(node);
1841
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasNavigate && !hasLink && !hasSubmit && !hasFormSubmit) {
1842
+ return {
1843
+ ruleId: "interaction-button-action",
1844
+ category: "interaction",
1845
+ severity: "warning",
1846
+ message: buttonText ? `Button "${buttonText}" has no action defined` : "Button has no action defined",
1847
+ description: "Interactive buttons need an action to respond to user clicks",
1848
+ suggestion: "Add onClick, action, href, or navigate attribute to define what happens when clicked",
1849
+ path: context.path,
1850
+ nodeType: node.type,
1851
+ location: getNodeLocation(node)
1852
+ };
1853
+ }
1854
+ return null;
1855
+ }
1856
+ };
1857
+ var linkHasDestination = {
1858
+ id: "interaction-link-destination",
1859
+ category: "interaction",
1860
+ severity: "warning",
1861
+ name: "Link should have destination",
1862
+ description: "Links should have an href or navigation target defined",
1863
+ appliesTo: ["Link", "NavLink", "Anchor"],
1864
+ check: (node, context) => {
1865
+ const hasHref = "href" in node && node.href;
1866
+ const hasTo = "to" in node && node.to;
1867
+ const hasNavigate = "navigate" in node;
1868
+ const hasUrl = "url" in node && node.url;
1869
+ const linkText = getNodeText(node);
1870
+ if (!hasHref && !hasTo && !hasNavigate && !hasUrl) {
1871
+ return {
1872
+ ruleId: "interaction-link-destination",
1873
+ category: "interaction",
1874
+ severity: "warning",
1875
+ message: linkText ? `Link "${linkText}" has no destination` : "Link has no destination defined",
1876
+ description: "Links need a destination URL or route to navigate to",
1877
+ suggestion: "Add href, to, or navigate attribute to define the link destination",
1878
+ path: context.path,
1879
+ nodeType: node.type,
1880
+ location: getNodeLocation(node)
1881
+ };
1882
+ }
1883
+ return null;
1884
+ }
1885
+ };
1886
+ var formHasSubmitAction = {
1887
+ id: "interaction-form-submit",
1888
+ category: "interaction",
1889
+ severity: "warning",
1890
+ name: "Form should have submit action",
1891
+ description: "Forms should have an onSubmit action or form action defined",
1892
+ appliesTo: ["Form"],
1893
+ check: (node, context) => {
1894
+ const hasOnSubmit = "onSubmit" in node;
1895
+ const hasAction = "action" in node && node.action;
1896
+ if (!hasOnSubmit && !hasAction) {
1897
+ return {
1898
+ ruleId: "interaction-form-submit",
1899
+ category: "interaction",
1900
+ severity: "warning",
1901
+ message: "Form has no submit action defined",
1902
+ description: "Forms need an action to handle submission",
1903
+ suggestion: "Add onSubmit or action attribute to define form submission behavior",
1904
+ path: context.path,
1905
+ nodeType: node.type,
1906
+ location: getNodeLocation(node)
1907
+ };
1908
+ }
1909
+ return null;
1910
+ }
1911
+ };
1912
+ var tabItemHasTarget = {
1913
+ id: "interaction-tab-target",
1914
+ category: "interaction",
1915
+ severity: "warning",
1916
+ name: "Tab item should have target",
1917
+ description: "Tab items should link to content or have an action",
1918
+ appliesTo: ["Tab", "TabItem"],
1919
+ check: (node, context) => {
1920
+ const hasTarget = "target" in node;
1921
+ const hasPanel = "panel" in node;
1922
+ const hasContent = "content" in node;
1923
+ const hasOnClick = "onClick" in node;
1924
+ const hasValue = "value" in node;
1925
+ const tabLabel = getNodeText(node);
1926
+ if (!hasTarget && !hasPanel && !hasContent && !hasOnClick && !hasValue) {
1927
+ return {
1928
+ ruleId: "interaction-tab-target",
1929
+ category: "interaction",
1930
+ severity: "warning",
1931
+ message: tabLabel ? `Tab "${tabLabel}" has no target or action` : "Tab item has no target or action defined",
1932
+ description: "Tab items need to reference content or trigger an action",
1933
+ suggestion: "Add target, panel, value, or onClick attribute",
1934
+ path: context.path,
1935
+ nodeType: node.type,
1936
+ location: getNodeLocation(node)
1937
+ };
1938
+ }
1939
+ return null;
1940
+ }
1941
+ };
1942
+ var menuItemHasAction = {
1943
+ id: "interaction-menu-action",
1944
+ category: "interaction",
1945
+ severity: "warning",
1946
+ name: "Menu item should have action",
1947
+ description: "Menu items should have an onClick, href, or action defined",
1948
+ appliesTo: ["MenuItem", "DropdownItem"],
1949
+ check: (node, context) => {
1950
+ const hasOnClick = "onClick" in node;
1951
+ const hasAction = "action" in node;
1952
+ const hasHref = "href" in node;
1953
+ const hasTo = "to" in node;
1954
+ const hasCommand = "command" in node;
1955
+ const itemText = getNodeText(node);
1956
+ if ("divider" in node || "separator" in node) {
1957
+ return null;
1958
+ }
1959
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo && !hasCommand) {
1960
+ return {
1961
+ ruleId: "interaction-menu-action",
1962
+ category: "interaction",
1963
+ severity: "warning",
1964
+ message: itemText ? `Menu item "${itemText}" has no action` : "Menu item has no action defined",
1965
+ description: "Menu items need an action to respond to selection",
1966
+ suggestion: "Add onClick, action, href, or command attribute",
1967
+ path: context.path,
1968
+ nodeType: node.type,
1969
+ location: getNodeLocation(node)
1970
+ };
1971
+ }
1972
+ return null;
1973
+ }
1974
+ };
1975
+ var clickableCardHasAction = {
1976
+ id: "interaction-card-action",
1977
+ category: "interaction",
1978
+ severity: "info",
1979
+ name: "Clickable card should have action",
1980
+ description: "Cards marked as clickable should have an action defined",
1981
+ appliesTo: ["Card"],
1982
+ check: (node, context) => {
1983
+ const isClickable = "clickable" in node || "hoverable" in node || "interactive" in node;
1984
+ if (!isClickable) return null;
1985
+ const hasOnClick = "onClick" in node;
1986
+ const hasHref = "href" in node;
1987
+ const hasTo = "to" in node;
1988
+ const hasAction = "action" in node;
1989
+ if (!hasOnClick && !hasHref && !hasTo && !hasAction) {
1990
+ return {
1991
+ ruleId: "interaction-card-action",
1992
+ category: "interaction",
1993
+ severity: "info",
1994
+ message: "Clickable card has no action defined",
1995
+ description: "Cards marked as clickable/interactive need an action",
1996
+ suggestion: "Add onClick, href, or action attribute to define click behavior",
1997
+ path: context.path,
1998
+ nodeType: node.type,
1999
+ location: getNodeLocation(node)
2000
+ };
2001
+ }
2002
+ return null;
2003
+ }
2004
+ };
2005
+ var iconButtonHasAction = {
2006
+ id: "interaction-icon-button-action",
2007
+ category: "interaction",
2008
+ severity: "warning",
2009
+ name: "Icon button should have action",
2010
+ description: "Icon buttons should have an action defined",
2011
+ appliesTo: ["IconButton"],
2012
+ check: (node, context) => {
2013
+ const hasOnClick = "onClick" in node;
2014
+ const hasAction = "action" in node;
2015
+ const hasHref = "href" in node;
2016
+ const hasTo = "to" in node;
2017
+ const iconName = "icon" in node ? String(node.icon || "") : "name" in node ? String(node.name || "") : "";
2018
+ if (!hasOnClick && !hasAction && !hasHref && !hasTo) {
2019
+ return {
2020
+ ruleId: "interaction-icon-button-action",
2021
+ category: "interaction",
2022
+ severity: "warning",
2023
+ message: iconName ? `Icon button "${iconName}" has no action defined` : "Icon button has no action defined",
2024
+ description: "Icon buttons need an action to respond to clicks",
2025
+ suggestion: "Add onClick, action, or href attribute",
2026
+ path: context.path,
2027
+ nodeType: node.type,
2028
+ location: getNodeLocation(node)
2029
+ };
2030
+ }
2031
+ return null;
2032
+ }
2033
+ };
2034
+ var modalHasCloseAction = {
2035
+ id: "interaction-modal-close",
2036
+ category: "interaction",
2037
+ severity: "info",
2038
+ name: "Modal should have close mechanism",
2039
+ description: "Modals should have a way to close them",
2040
+ appliesTo: ["Modal", "Dialog"],
2041
+ check: (node, context) => {
2042
+ const hasOnClose = "onClose" in node;
2043
+ const hasCloseButton = "closable" in node || "closeButton" in node;
2044
+ const hasDismiss = "dismissible" in node || "dismiss" in node;
2045
+ const hasCloseChild = hasChildren(node) && hasChildMatching(node, (child) => {
2046
+ if (child.type === "Button") {
2047
+ const text = "text" in child ? String(child.text || "").toLowerCase() : "";
2048
+ const label = "label" in child ? String(child.label || "").toLowerCase() : "";
2049
+ const action = "action" in child ? String(child.action || "").toLowerCase() : "";
2050
+ return text.includes("close") || text.includes("cancel") || label.includes("close") || label.includes("cancel") || action.includes("close");
2051
+ }
2052
+ return false;
2053
+ });
2054
+ if (!hasOnClose && !hasCloseButton && !hasDismiss && !hasCloseChild) {
2055
+ return {
2056
+ ruleId: "interaction-modal-close",
2057
+ category: "interaction",
2058
+ severity: "info",
2059
+ message: "Modal has no close mechanism defined",
2060
+ description: "Users need a way to close modals",
2061
+ suggestion: "Add onClose, closable attribute, or a close/cancel button",
2062
+ path: context.path,
2063
+ nodeType: node.type,
2064
+ location: getNodeLocation(node)
2065
+ };
2066
+ }
2067
+ return null;
2068
+ }
2069
+ };
2070
+ var interactionRules = [
2071
+ buttonHasAction,
2072
+ linkHasDestination,
2073
+ formHasSubmitAction,
2074
+ tabItemHasTarget,
2075
+ menuItemHasAction,
2076
+ clickableCardHasAction,
2077
+ iconButtonHasAction,
2078
+ modalHasCloseAction
2079
+ ];
2080
+
1003
2081
  // src/rules/index.ts
1004
2082
  var allRules = [
1005
2083
  ...accessibilityRules,
@@ -1007,7 +2085,11 @@ var allRules = [
1007
2085
  ...touchTargetRules,
1008
2086
  ...consistencyRules,
1009
2087
  ...usabilityRules,
1010
- ...navigationRules
2088
+ ...navigationRules,
2089
+ ...contentRules,
2090
+ ...dataDisplayRules,
2091
+ ...feedbackRules,
2092
+ ...interactionRules
1011
2093
  ];
1012
2094
  var rulesByCategory = {
1013
2095
  accessibility: accessibilityRules,
@@ -1015,7 +2097,11 @@ var rulesByCategory = {
1015
2097
  "touch-target": touchTargetRules,
1016
2098
  consistency: consistencyRules,
1017
2099
  usability: usabilityRules,
1018
- navigation: navigationRules
2100
+ navigation: navigationRules,
2101
+ content: contentRules,
2102
+ "data-display": dataDisplayRules,
2103
+ feedback: feedbackRules,
2104
+ interaction: interactionRules
1019
2105
  };
1020
2106
  function getRulesForCategories(categories) {
1021
2107
  if (categories.length === 0) return allRules;
@@ -1039,7 +2125,6 @@ function validateUX(ast, options = {}) {
1039
2125
  customRules = [],
1040
2126
  disabledRules = []
1041
2127
  } = options;
1042
- resetConsistencyTrackers();
1043
2128
  let rules = categories.length > 0 ? getRulesForCategories(categories) : allRules;
1044
2129
  rules = [...rules, ...customRules];
1045
2130
  if (disabledRules.length > 0) {
@@ -1076,10 +2161,11 @@ function validateUX(ast, options = {}) {
1076
2161
  return true;
1077
2162
  }
1078
2163
  function walkNode(node, path, parent, siblings, index, depth) {
2164
+ const rootNode = ast.children?.[0];
1079
2165
  const context = {
1080
2166
  path,
1081
2167
  parent,
1082
- root: ast.children[0],
2168
+ root: isAnyNode(rootNode) ? rootNode : null,
1083
2169
  siblings,
1084
2170
  index,
1085
2171
  depth
@@ -1124,9 +2210,9 @@ function validateUX(ast, options = {}) {
1124
2210
  return true;
1125
2211
  }
1126
2212
  if (ast.children) {
1127
- for (let i = 0; i < ast.children.length; i++) {
1128
- const page = ast.children[i];
1129
- const children = ast.children;
2213
+ const children = toAnyNodeArray(ast.children);
2214
+ for (let i = 0; i < children.length; i++) {
2215
+ const page = children[i];
1130
2216
  walkNode(page, `pages[${i}]`, null, children, i, 0);
1131
2217
  }
1132
2218
  }
@@ -1191,12 +2277,59 @@ function formatUXResult(result) {
1191
2277
  return lines.join("\n");
1192
2278
  }
1193
2279
  export {
2280
+ ASYNC_ACTION_WORDS,
2281
+ CLOSE_WORDS,
2282
+ CONTAINER_TYPES,
2283
+ DESTRUCTIVE_WORDS,
2284
+ ERROR_WORDS,
2285
+ FORM_INPUT_TYPES,
2286
+ GENERIC_LINK_TEXTS,
2287
+ HOME_WORDS,
2288
+ INPUT_TYPE_SUGGESTIONS,
2289
+ MAX_BUTTONS,
2290
+ MAX_BUTTON_TEXT_LENGTH,
2291
+ MAX_FORM_FIELDS,
2292
+ MAX_LIST_ITEMS,
2293
+ MAX_NAV_ITEMS,
2294
+ MAX_NESTING_DEPTH,
2295
+ MAX_PAGE_ELEMENTS,
2296
+ MAX_TABLE_COLUMNS,
2297
+ MAX_TABS,
2298
+ MAX_TITLE_LENGTH,
2299
+ MAX_TOAST_DURATION,
2300
+ MAX_TOOLTIP_LENGTH,
2301
+ MIN_TOAST_DURATION,
2302
+ MIN_TOUCH_TARGET,
2303
+ PLACEHOLDER_PATTERNS,
2304
+ RECOMMENDED_TOUCH_TARGET,
2305
+ SIZE_MAP,
2306
+ SUBMIT_WORDS,
2307
+ SUCCESS_WORDS,
2308
+ TEXT_CONTENT_TYPES,
2309
+ WARNING_WORDS,
1194
2310
  allRules,
2311
+ containsAnyWord,
2312
+ countInChildren,
2313
+ filterChildren,
2314
+ findInChildren,
1195
2315
  formatUXResult,
2316
+ getButtonStyle,
2317
+ getChildren,
2318
+ getNodeItems,
2319
+ getNodeLocation,
2320
+ getNodeText,
2321
+ getNodeTextLower,
1196
2322
  getRulesForCategories,
2323
+ getSizeValue,
1197
2324
  getUXIssues,
1198
2325
  getUXScore,
2326
+ hasChildMatching,
2327
+ hasChildren,
2328
+ isAnyNode,
2329
+ isNodeType,
1199
2330
  isUXValid,
2331
+ matchesAnyWord,
1200
2332
  rulesByCategory,
2333
+ toAnyNodeArray,
1201
2334
  validateUX
1202
2335
  };