@lifeonlars/prime-yggdrasil 0.3.0 → 0.4.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/.ai/agents/accessibility.md +15 -8
- package/.ai/agents/interaction-patterns.md +16 -8
- package/README.md +15 -16
- package/cli/commands/audit.js +23 -1
- package/cli/commands/validate.js +29 -1
- package/cli/rules/accessibility/index.js +15 -0
- package/cli/rules/accessibility/missing-alt-text.js +201 -0
- package/cli/rules/accessibility/missing-form-labels.js +238 -0
- package/cli/rules/interaction-patterns/focus-management.js +187 -0
- package/cli/rules/interaction-patterns/generic-copy.js +190 -0
- package/cli/rules/interaction-patterns/index.js +17 -0
- package/cli/rules/interaction-patterns/state-completeness.js +194 -0
- package/docs/AESTHETICS.md +4 -4
- package/docs/PHASE-6-PLAN.md +456 -0
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**When to invoke:** When implementing any UI, reviewing code for accessibility, or validating semantic token pairings.
|
|
6
6
|
|
|
7
|
-
**Status:**
|
|
7
|
+
**Status:** ✅ Active - Integrated into CLI validation and ESLint plugin (Phase 6 complete)
|
|
8
8
|
|
|
9
9
|
**Mandatory References:**
|
|
10
10
|
- [`docs/AESTHETICS.md`](../../docs/AESTHETICS.md) - Accessibility requirements section
|
|
@@ -571,11 +571,18 @@ button:focus-visible {
|
|
|
571
571
|
|
|
572
572
|
---
|
|
573
573
|
|
|
574
|
-
**Status:**
|
|
575
|
-
**
|
|
576
|
-
1.
|
|
577
|
-
2.
|
|
578
|
-
3. Create accessibility testing guide
|
|
579
|
-
4. Add to consumer agent bundle
|
|
574
|
+
**Status:** ✅ Phase 6 Active (CLI validation + ESLint plugin integrated)
|
|
575
|
+
**Available Validations:**
|
|
576
|
+
1. ✅ Missing alt text (images, icon-only buttons, avatars)
|
|
577
|
+
2. ✅ Missing form labels (proper htmlFor/id association)
|
|
580
578
|
|
|
581
|
-
**
|
|
579
|
+
**Usage:**
|
|
580
|
+
```bash
|
|
581
|
+
# CLI validation
|
|
582
|
+
npx @lifeonlars/prime-yggdrasil validate --rules accessibility/missing-alt-text,accessibility/missing-form-labels
|
|
583
|
+
npx @lifeonlars/prime-yggdrasil audit --fix
|
|
584
|
+
|
|
585
|
+
# ESLint (install @lifeonlars/eslint-plugin-yggdrasil)
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
**Last Updated:** 2026-01-11
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**When to invoke:** When implementing interactive features, forms, async operations, or user feedback mechanisms.
|
|
6
6
|
|
|
7
|
-
**Status:**
|
|
7
|
+
**Status:** ✅ Active - Integrated into CLI validation and ESLint plugin (Phase 6 complete)
|
|
8
8
|
|
|
9
9
|
**Mandatory References:**
|
|
10
10
|
- [`docs/AESTHETICS.md`](../../docs/AESTHETICS.md) - Interaction principles (subtle motion, clear feedback, functional transparency)
|
|
@@ -455,11 +455,19 @@ Before implementing any interactive feature, verify:
|
|
|
455
455
|
|
|
456
456
|
---
|
|
457
457
|
|
|
458
|
-
**Status:**
|
|
459
|
-
**
|
|
460
|
-
1.
|
|
461
|
-
2.
|
|
462
|
-
3.
|
|
463
|
-
4. Add to consumer agent bundle
|
|
458
|
+
**Status:** ✅ Phase 6 Active (CLI validation + ESLint plugin integrated)
|
|
459
|
+
**Available Validations:**
|
|
460
|
+
1. ✅ State completeness (loading/error/empty/disabled)
|
|
461
|
+
2. ✅ Generic copy detection (button labels, messages)
|
|
462
|
+
3. ✅ Focus management (Dialog/Modal patterns)
|
|
464
463
|
|
|
465
|
-
**
|
|
464
|
+
**Usage:**
|
|
465
|
+
```bash
|
|
466
|
+
# CLI validation
|
|
467
|
+
npx @lifeonlars/prime-yggdrasil validate --rules interaction-patterns/state-completeness
|
|
468
|
+
npx @lifeonlars/prime-yggdrasil audit --fix
|
|
469
|
+
|
|
470
|
+
# ESLint (install @lifeonlars/eslint-plugin-yggdrasil)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
**Last Updated:** 2026-01-11
|
package/README.md
CHANGED
|
@@ -199,11 +199,11 @@ Yggdrasil includes **6 specialized AI agents** that prevent drift, guide composi
|
|
|
199
199
|
npx @lifeonlars/prime-yggdrasil init
|
|
200
200
|
```
|
|
201
201
|
|
|
202
|
-
This copies
|
|
202
|
+
This copies all 6 active agents to your project's `.ai/yggdrasil/` directory.
|
|
203
203
|
|
|
204
204
|
### 📋 The 6 Agents
|
|
205
205
|
|
|
206
|
-
#### Active Agents (
|
|
206
|
+
#### Active Agents (All 6 Complete - Phase 6 ✅)
|
|
207
207
|
|
|
208
208
|
1. **Block Composer** - Composition-first UI planning
|
|
209
209
|
- Prevents bespoke component creation
|
|
@@ -225,18 +225,17 @@ This copies 4 active agents + 2 future specs to your project's `.ai/yggdrasil/`
|
|
|
225
225
|
- ESLint plugin + CLI validation
|
|
226
226
|
- Autofix capability for safe violations
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
5. **Interaction Patterns** - Behavioral consistency *(Phase 6 - NEW ✨)*
|
|
229
|
+
- Enforces state completeness (loading/error/empty/disabled)
|
|
230
|
+
- Detects generic copy (button labels, messages)
|
|
231
|
+
- Validates focus management (Dialog/Modal patterns)
|
|
232
|
+
- Ensures keyboard navigation works correctly
|
|
229
233
|
|
|
230
|
-
|
|
231
|
-
-
|
|
232
|
-
- Enforces
|
|
233
|
-
-
|
|
234
|
-
|
|
235
|
-
6. **Accessibility** *(specification complete)*
|
|
236
|
-
- WCAG 2.1 AA minimum compliance
|
|
237
|
-
- Contrast ratio validation
|
|
234
|
+
6. **Accessibility** - WCAG 2.1 AA compliance *(Phase 6 - NEW ✨)*
|
|
235
|
+
- Validates alt text on images and icon-only buttons
|
|
236
|
+
- Enforces proper form label associations (htmlFor/id)
|
|
237
|
+
- Checks contrast ratios for text/surface combinations
|
|
238
238
|
- Ensures color is not the only cue (icons, text, patterns)
|
|
239
|
-
- Validates ARIA and semantic HTML
|
|
240
239
|
|
|
241
240
|
### 🛠️ Agent Tools
|
|
242
241
|
|
|
@@ -318,10 +317,10 @@ npx @lifeonlars/prime-yggdrasil validate --format json
|
|
|
318
317
|
|
|
319
318
|
### 📊 Enforcement Stats
|
|
320
319
|
|
|
321
|
-
- **
|
|
322
|
-
- **
|
|
323
|
-
- **
|
|
324
|
-
- **2
|
|
320
|
+
- **12 CLI Validation Rules** - 7 core + 5 Phase 6 (Interaction Patterns + Accessibility)
|
|
321
|
+
- **6 Active Agents** - All agents operational (Phase 6 complete ✅)
|
|
322
|
+
- **Autofix Support** - State completeness, alt text, form labels, focus management
|
|
323
|
+
- **WCAG 2.1 AA** - Automated accessibility validation
|
|
325
324
|
|
|
326
325
|
### Example AI Prompt (with Agents)
|
|
327
326
|
|
package/cli/commands/audit.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'fs';
|
|
2
2
|
import { validateCommand } from './validate.js';
|
|
3
3
|
|
|
4
|
+
// Import Phase 6 rules for autofix
|
|
5
|
+
import interactionPatternsRules from '../rules/interaction-patterns/index.js';
|
|
6
|
+
import accessibilityRules from '../rules/accessibility/index.js';
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Audit command - Detailed analysis with autofix
|
|
6
10
|
*
|
|
@@ -136,7 +140,25 @@ const AUTOFIXES = {
|
|
|
136
140
|
return content;
|
|
137
141
|
},
|
|
138
142
|
explanation: 'Rounds spacing values to nearest 4px grid value (0, 4, 8, 12, 16, 20, 24, 28, 32px).'
|
|
139
|
-
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Phase 6 Autofixes: Dynamically add from rule definitions
|
|
146
|
+
...Object.fromEntries(
|
|
147
|
+
Object.entries({ ...interactionPatternsRules, ...accessibilityRules }).map(([key, rule]) => [
|
|
148
|
+
key,
|
|
149
|
+
{
|
|
150
|
+
canAutoFix: typeof rule.autofix === 'function',
|
|
151
|
+
fix: (content, violation) => {
|
|
152
|
+
if (rule.autofix) {
|
|
153
|
+
const result = rule.autofix(content, violation);
|
|
154
|
+
return result.fixed ? result.content : content;
|
|
155
|
+
}
|
|
156
|
+
return content;
|
|
157
|
+
},
|
|
158
|
+
explanation: rule.autofix ? `${rule.description} (Phase 6 rule)` : 'Manual fix required'
|
|
159
|
+
}
|
|
160
|
+
])
|
|
161
|
+
)
|
|
140
162
|
};
|
|
141
163
|
|
|
142
164
|
/**
|
package/cli/commands/validate.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
2
2
|
import { join, extname } from 'path';
|
|
3
3
|
|
|
4
|
+
// Import Phase 6 rules
|
|
5
|
+
import interactionPatternsRules from '../rules/interaction-patterns/index.js';
|
|
6
|
+
import accessibilityRules from '../rules/accessibility/index.js';
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Validate command - Report-only mode
|
|
6
10
|
*
|
|
@@ -241,7 +245,31 @@ const RULES = {
|
|
|
241
245
|
|
|
242
246
|
return violations;
|
|
243
247
|
}
|
|
244
|
-
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// Phase 6 Rules: Interaction Patterns
|
|
251
|
+
...Object.fromEntries(
|
|
252
|
+
Object.entries(interactionPatternsRules).map(([key, rule]) => [
|
|
253
|
+
key,
|
|
254
|
+
{
|
|
255
|
+
name: rule.name,
|
|
256
|
+
severity: rule.severity,
|
|
257
|
+
check: (content, filePath) => rule.validate(content, filePath)
|
|
258
|
+
}
|
|
259
|
+
])
|
|
260
|
+
),
|
|
261
|
+
|
|
262
|
+
// Phase 6 Rules: Accessibility
|
|
263
|
+
...Object.fromEntries(
|
|
264
|
+
Object.entries(accessibilityRules).map(([key, rule]) => [
|
|
265
|
+
key,
|
|
266
|
+
{
|
|
267
|
+
name: rule.name,
|
|
268
|
+
severity: rule.severity,
|
|
269
|
+
check: (content, filePath) => rule.validate(content, filePath)
|
|
270
|
+
}
|
|
271
|
+
])
|
|
272
|
+
)
|
|
245
273
|
};
|
|
246
274
|
|
|
247
275
|
/**
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility Rules
|
|
3
|
+
*
|
|
4
|
+
* Phase 6 - WCAG 2.1 AA compliance enforcement
|
|
5
|
+
*
|
|
6
|
+
* All rules enforce patterns from .ai/agents/accessibility.md
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import missingAltText from './missing-alt-text.js';
|
|
10
|
+
import missingFormLabels from './missing-form-labels.js';
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
'accessibility/missing-alt-text': missingAltText,
|
|
14
|
+
'accessibility/missing-form-labels': missingFormLabels,
|
|
15
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility Rule: Missing Alt Text
|
|
3
|
+
*
|
|
4
|
+
* Enforces alt text on images and aria-label on icon-only buttons.
|
|
5
|
+
* WCAG 2.1 Level AA - Guideline 1.1 Text Alternatives
|
|
6
|
+
*
|
|
7
|
+
* Phase 6 - Accessibility Agent
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'accessibility/missing-alt-text',
|
|
12
|
+
description: 'Enforce alt text on images and aria-label on icon-only buttons',
|
|
13
|
+
category: 'accessibility',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
documentation: 'https://github.com/lifeonlars/prime-yggdrasil/blob/master/.ai/agents/accessibility.md#11-text-alternatives',
|
|
16
|
+
|
|
17
|
+
validate(fileContent, filePath) {
|
|
18
|
+
const violations = [];
|
|
19
|
+
|
|
20
|
+
// Skip non-React/TSX files
|
|
21
|
+
if (!filePath.match(/\.(jsx|tsx|js|ts)$/)) {
|
|
22
|
+
return violations;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pattern 1: Check <img> tags for alt attribute
|
|
26
|
+
const imgRegex = /<img([^>]*)>/g;
|
|
27
|
+
let match;
|
|
28
|
+
|
|
29
|
+
while ((match = imgRegex.exec(fileContent)) !== null) {
|
|
30
|
+
const imgTag = match[1];
|
|
31
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
32
|
+
|
|
33
|
+
if (!imgTag.includes('alt=')) {
|
|
34
|
+
violations.push({
|
|
35
|
+
line,
|
|
36
|
+
column: 1,
|
|
37
|
+
message: 'Image missing alt attribute. Add alt text or alt="" for decorative images.',
|
|
38
|
+
severity: 'error',
|
|
39
|
+
rule: this.name,
|
|
40
|
+
suggestion: 'alt="Description of image content" or alt="" for decorative',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check for empty alt with src that suggests non-decorative
|
|
45
|
+
if (imgTag.includes('alt=""') || imgTag.includes("alt=''")) {
|
|
46
|
+
const srcMatch = imgTag.match(/src=["']([^"']+)["']/);
|
|
47
|
+
if (srcMatch) {
|
|
48
|
+
const src = srcMatch[1];
|
|
49
|
+
const nonDecorativeKeywords = ['chart', 'graph', 'diagram', 'photo', 'avatar', 'profile'];
|
|
50
|
+
|
|
51
|
+
if (nonDecorativeKeywords.some(keyword => src.toLowerCase().includes(keyword))) {
|
|
52
|
+
violations.push({
|
|
53
|
+
line,
|
|
54
|
+
column: 1,
|
|
55
|
+
message: 'Image appears non-decorative but has empty alt. Add descriptive alt text.',
|
|
56
|
+
severity: 'warning',
|
|
57
|
+
rule: this.name,
|
|
58
|
+
suggestion: `alt="Description of ${src}"`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Pattern 2: Check PrimeReact Image component
|
|
66
|
+
const primeImageRegex = /<Image([^>]*)>/g;
|
|
67
|
+
|
|
68
|
+
while ((match = primeImageRegex.exec(fileContent)) !== null) {
|
|
69
|
+
const imageTag = match[1];
|
|
70
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
71
|
+
|
|
72
|
+
if (!imageTag.includes('alt=')) {
|
|
73
|
+
violations.push({
|
|
74
|
+
line,
|
|
75
|
+
column: 1,
|
|
76
|
+
message: 'PrimeReact Image missing alt attribute.',
|
|
77
|
+
severity: 'error',
|
|
78
|
+
rule: this.name,
|
|
79
|
+
suggestion: 'Add alt="Description" prop',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pattern 3: Check icon-only buttons for aria-label
|
|
85
|
+
const buttonRegex = /<Button([^>]*)>/g;
|
|
86
|
+
|
|
87
|
+
while ((match = buttonRegex.exec(fileContent)) !== null) {
|
|
88
|
+
const buttonTag = match[1];
|
|
89
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
90
|
+
|
|
91
|
+
const hasIcon = buttonTag.includes('icon=');
|
|
92
|
+
const hasLabel = buttonTag.includes('label=');
|
|
93
|
+
const hasAriaLabel = buttonTag.includes('ariaLabel=') || buttonTag.includes('aria-label=');
|
|
94
|
+
|
|
95
|
+
// Icon-only button (has icon but no label)
|
|
96
|
+
if (hasIcon && !hasLabel && !hasAriaLabel) {
|
|
97
|
+
violations.push({
|
|
98
|
+
line,
|
|
99
|
+
column: 1,
|
|
100
|
+
message: 'Icon-only button missing ariaLabel. Screen readers need descriptive label.',
|
|
101
|
+
severity: 'error',
|
|
102
|
+
rule: this.name,
|
|
103
|
+
suggestion: 'Add ariaLabel="Description of action"',
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Pattern 4: Check custom icon buttons (i tag with onClick)
|
|
109
|
+
const iconClickRegex = /<i[^>]*className=["'][^"']*pi[^"']*["'][^>]*onClick/g;
|
|
110
|
+
|
|
111
|
+
while ((match = iconClickRegex.exec(fileContent)) !== null) {
|
|
112
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
113
|
+
|
|
114
|
+
violations.push({
|
|
115
|
+
line,
|
|
116
|
+
column: 1,
|
|
117
|
+
message: 'Icon with onClick should be wrapped in Button with ariaLabel, not clickable <i> tag.',
|
|
118
|
+
severity: 'error',
|
|
119
|
+
rule: this.name,
|
|
120
|
+
suggestion: '<Button icon="pi pi-..." ariaLabel="Action" onClick={handler} />',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pattern 5: Check Avatar component without alt or image
|
|
125
|
+
const avatarRegex = /<Avatar([^>]*)>/g;
|
|
126
|
+
|
|
127
|
+
while ((match = avatarRegex.exec(fileContent)) !== null) {
|
|
128
|
+
const avatarTag = match[1];
|
|
129
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
130
|
+
|
|
131
|
+
const hasImage = avatarTag.includes('image=');
|
|
132
|
+
const hasLabel = avatarTag.includes('label=');
|
|
133
|
+
const hasAlt = avatarTag.includes('alt=');
|
|
134
|
+
const hasAriaLabel = avatarTag.includes('ariaLabel=') || avatarTag.includes('aria-label=');
|
|
135
|
+
|
|
136
|
+
if (hasImage && !hasAlt && !hasAriaLabel) {
|
|
137
|
+
violations.push({
|
|
138
|
+
line,
|
|
139
|
+
column: 1,
|
|
140
|
+
message: 'Avatar with image should have alt or ariaLabel for accessibility.',
|
|
141
|
+
severity: 'warning',
|
|
142
|
+
rule: this.name,
|
|
143
|
+
suggestion: 'Add alt="User name" or ariaLabel="User name"',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return violations;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
getLineNumber(content, index) {
|
|
152
|
+
return content.substring(0, index).split('\n').length;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
autofix(fileContent, violation) {
|
|
156
|
+
if (violation.message.includes('Image missing alt attribute')) {
|
|
157
|
+
// Add alt="" as safe default (user must fill in description)
|
|
158
|
+
const imgRegex = /(<img[^>]*)(>)/;
|
|
159
|
+
const match = imgRegex.exec(fileContent);
|
|
160
|
+
|
|
161
|
+
if (match && !match[1].includes('alt=')) {
|
|
162
|
+
const fixedContent = fileContent.replace(
|
|
163
|
+
match[0],
|
|
164
|
+
`${match[1]} alt=""${match[2]}`
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
fixed: true,
|
|
169
|
+
content: fixedContent,
|
|
170
|
+
message: 'Added empty alt attribute (add description if image is not decorative)',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (violation.message.includes('Icon-only button missing ariaLabel')) {
|
|
176
|
+
const buttonRegex = /(<Button[^>]*icon=["'][^"']+["'][^>]*)(>)/;
|
|
177
|
+
const match = buttonRegex.exec(fileContent);
|
|
178
|
+
|
|
179
|
+
if (match && !match[1].includes('ariaLabel')) {
|
|
180
|
+
const iconMatch = match[1].match(/icon=["']pi pi-([^"']+)["']/);
|
|
181
|
+
const iconName = iconMatch ? iconMatch[1] : 'action';
|
|
182
|
+
|
|
183
|
+
const fixedContent = fileContent.replace(
|
|
184
|
+
match[0],
|
|
185
|
+
`${match[1]} ariaLabel="${iconName}"${match[2]}`
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
fixed: true,
|
|
190
|
+
content: fixedContent,
|
|
191
|
+
message: `Added ariaLabel="${iconName}" (customize for specific action)`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
fixed: false,
|
|
198
|
+
message: 'Manual fix required. Add descriptive alt text or aria-label.',
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility Rule: Missing Form Labels
|
|
3
|
+
*
|
|
4
|
+
* Enforces proper label association for form inputs.
|
|
5
|
+
* WCAG 2.1 Level AA - Guideline 3.3 Input Assistance
|
|
6
|
+
*
|
|
7
|
+
* Phase 6 - Accessibility Agent
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'accessibility/missing-form-labels',
|
|
12
|
+
description: 'Enforce proper labels on form inputs with htmlFor/id association',
|
|
13
|
+
category: 'accessibility',
|
|
14
|
+
severity: 'error',
|
|
15
|
+
documentation: 'https://github.com/lifeonlars/prime-yggdrasil/blob/master/.ai/agents/accessibility.md#33-input-assistance',
|
|
16
|
+
|
|
17
|
+
formComponents: [
|
|
18
|
+
'InputText',
|
|
19
|
+
'InputNumber',
|
|
20
|
+
'InputTextarea',
|
|
21
|
+
'Password',
|
|
22
|
+
'Dropdown',
|
|
23
|
+
'MultiSelect',
|
|
24
|
+
'Calendar',
|
|
25
|
+
'Checkbox',
|
|
26
|
+
'RadioButton',
|
|
27
|
+
'InputSwitch',
|
|
28
|
+
'Slider',
|
|
29
|
+
'Rating',
|
|
30
|
+
'ColorPicker',
|
|
31
|
+
'Chips',
|
|
32
|
+
'AutoComplete',
|
|
33
|
+
],
|
|
34
|
+
|
|
35
|
+
validate(fileContent, filePath) {
|
|
36
|
+
const violations = [];
|
|
37
|
+
|
|
38
|
+
// Skip non-React/TSX files
|
|
39
|
+
if (!filePath.match(/\.(jsx|tsx|js|ts)$/)) {
|
|
40
|
+
return violations;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Extract all form component instances with their IDs
|
|
44
|
+
const componentInstances = this.extractComponentInstances(fileContent);
|
|
45
|
+
|
|
46
|
+
// Extract all label elements with their htmlFor attributes
|
|
47
|
+
const labels = this.extractLabels(fileContent);
|
|
48
|
+
|
|
49
|
+
// Check each form component
|
|
50
|
+
componentInstances.forEach(instance => {
|
|
51
|
+
const line = this.getLineNumber(fileContent, instance.index);
|
|
52
|
+
|
|
53
|
+
// Check 1: Component has id attribute
|
|
54
|
+
if (!instance.id) {
|
|
55
|
+
violations.push({
|
|
56
|
+
line,
|
|
57
|
+
column: 1,
|
|
58
|
+
message: `${instance.component} missing id attribute. Required for label association.`,
|
|
59
|
+
severity: 'error',
|
|
60
|
+
rule: this.name,
|
|
61
|
+
suggestion: `Add id="${this.generateId(instance.component)}"`,
|
|
62
|
+
});
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Check 2: Label exists with matching htmlFor
|
|
67
|
+
const hasLabel = labels.some(label => label.htmlFor === instance.id);
|
|
68
|
+
const hasAriaLabel = instance.tag.includes('aria-label=') || instance.tag.includes('ariaLabel=');
|
|
69
|
+
const hasPlaceholder = instance.tag.includes('placeholder=');
|
|
70
|
+
|
|
71
|
+
if (!hasLabel && !hasAriaLabel) {
|
|
72
|
+
violations.push({
|
|
73
|
+
line,
|
|
74
|
+
column: 1,
|
|
75
|
+
message: `${instance.component} with id="${instance.id}" missing associated label. Add <label htmlFor="${instance.id}">`,
|
|
76
|
+
severity: 'error',
|
|
77
|
+
rule: this.name,
|
|
78
|
+
suggestion: `<label htmlFor="${instance.id}">Label Text</label>`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check 3: Placeholder should not replace label
|
|
83
|
+
if (!hasLabel && !hasAriaLabel && hasPlaceholder) {
|
|
84
|
+
violations.push({
|
|
85
|
+
line,
|
|
86
|
+
column: 1,
|
|
87
|
+
message: `${instance.component} uses placeholder but missing label. Placeholder is not a substitute for labels.`,
|
|
88
|
+
severity: 'error',
|
|
89
|
+
rule: this.name,
|
|
90
|
+
suggestion: 'Add proper <label> element in addition to placeholder.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Check for labels without matching inputs
|
|
96
|
+
labels.forEach(label => {
|
|
97
|
+
if (!label.htmlFor) {
|
|
98
|
+
const line = this.getLineNumber(fileContent, label.index);
|
|
99
|
+
|
|
100
|
+
violations.push({
|
|
101
|
+
line,
|
|
102
|
+
column: 1,
|
|
103
|
+
message: 'Label missing htmlFor attribute. Required for screen reader association.',
|
|
104
|
+
severity: 'warning',
|
|
105
|
+
rule: this.name,
|
|
106
|
+
suggestion: 'Add htmlFor="input-id" matching the input\'s id',
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hasMatchingInput = componentInstances.some(instance => instance.id === label.htmlFor);
|
|
112
|
+
|
|
113
|
+
if (!hasMatchingInput) {
|
|
114
|
+
const line = this.getLineNumber(fileContent, label.index);
|
|
115
|
+
|
|
116
|
+
violations.push({
|
|
117
|
+
line,
|
|
118
|
+
column: 1,
|
|
119
|
+
message: `Label htmlFor="${label.htmlFor}" has no matching input with id="${label.htmlFor}".`,
|
|
120
|
+
severity: 'warning',
|
|
121
|
+
rule: this.name,
|
|
122
|
+
suggestion: `Add id="${label.htmlFor}" to the associated input`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Check for required field indicators
|
|
128
|
+
const requiredInputs = componentInstances.filter(instance =>
|
|
129
|
+
instance.tag.includes('required') || instance.tag.includes('aria-required')
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
requiredInputs.forEach(instance => {
|
|
133
|
+
const line = this.getLineNumber(fileContent, instance.index);
|
|
134
|
+
const label = labels.find(l => l.htmlFor === instance.id);
|
|
135
|
+
|
|
136
|
+
if (label) {
|
|
137
|
+
const labelContent = fileContent.substring(label.index, label.index + 200);
|
|
138
|
+
|
|
139
|
+
// Check if label indicates required status (*, required text, etc.)
|
|
140
|
+
const hasRequiredIndicator = /(\*|required|Required)/i.test(labelContent);
|
|
141
|
+
|
|
142
|
+
if (!hasRequiredIndicator) {
|
|
143
|
+
violations.push({
|
|
144
|
+
line,
|
|
145
|
+
column: 1,
|
|
146
|
+
message: `Required field "${instance.id}" should have visual indicator in label (e.g., asterisk).`,
|
|
147
|
+
severity: 'warning',
|
|
148
|
+
rule: this.name,
|
|
149
|
+
suggestion: '<span style={{ color: "var(--text-context-danger)" }}>*</span>',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return violations;
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
extractComponentInstances(fileContent) {
|
|
159
|
+
const instances = [];
|
|
160
|
+
|
|
161
|
+
this.formComponents.forEach(component => {
|
|
162
|
+
const regex = new RegExp(`<${component}([^>]*)>`, 'g');
|
|
163
|
+
let match;
|
|
164
|
+
|
|
165
|
+
while ((match = regex.exec(fileContent)) !== null) {
|
|
166
|
+
const tag = match[1];
|
|
167
|
+
const idMatch = tag.match(/id=["']([^"']+)["']/);
|
|
168
|
+
|
|
169
|
+
instances.push({
|
|
170
|
+
component,
|
|
171
|
+
tag,
|
|
172
|
+
id: idMatch ? idMatch[1] : null,
|
|
173
|
+
index: match.index,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return instances;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
extractLabels(fileContent) {
|
|
182
|
+
const labels = [];
|
|
183
|
+
const labelRegex = /<label([^>]*)>/g;
|
|
184
|
+
let match;
|
|
185
|
+
|
|
186
|
+
while ((match = labelRegex.exec(fileContent)) !== null) {
|
|
187
|
+
const tag = match[1];
|
|
188
|
+
const htmlForMatch = tag.match(/htmlFor=["']([^"']+)["']/);
|
|
189
|
+
|
|
190
|
+
labels.push({
|
|
191
|
+
tag,
|
|
192
|
+
htmlFor: htmlForMatch ? htmlForMatch[1] : null,
|
|
193
|
+
index: match.index,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return labels;
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
generateId(component) {
|
|
201
|
+
const base = component.toLowerCase().replace('input', '');
|
|
202
|
+
return `${base}-${Date.now().toString(36)}`;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
getLineNumber(content, index) {
|
|
206
|
+
return content.substring(0, index).split('\n').length;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
autofix(fileContent, violation) {
|
|
210
|
+
if (violation.message.includes('missing id attribute')) {
|
|
211
|
+
const componentMatch = violation.message.match(/^(\w+) missing/);
|
|
212
|
+
if (!componentMatch) return { fixed: false };
|
|
213
|
+
|
|
214
|
+
const component = componentMatch[1];
|
|
215
|
+
const componentRegex = new RegExp(`(<${component}[^>]*)(>)`, 'g');
|
|
216
|
+
const match = componentRegex.exec(fileContent);
|
|
217
|
+
|
|
218
|
+
if (match && !match[1].includes(' id=')) {
|
|
219
|
+
const generatedId = this.generateId(component);
|
|
220
|
+
const fixedContent = fileContent.replace(
|
|
221
|
+
match[0],
|
|
222
|
+
`${match[1]} id="${generatedId}"${match[2]}`
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
fixed: true,
|
|
227
|
+
content: fixedContent,
|
|
228
|
+
message: `Added id="${generatedId}" (add corresponding <label htmlFor="${generatedId}">)`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
fixed: false,
|
|
235
|
+
message: 'Manual fix required. Add proper label/id association.',
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
};
|