@lifeonlars/prime-yggdrasil 0.2.6 → 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 +588 -0
- package/.ai/agents/block-composer.md +909 -0
- package/.ai/agents/drift-validator.md +784 -0
- package/.ai/agents/interaction-patterns.md +473 -0
- package/.ai/agents/primeflex-guard.md +815 -0
- package/.ai/agents/semantic-token-intent.md +739 -0
- package/README.md +138 -12
- package/cli/bin/yggdrasil.js +134 -0
- package/cli/commands/audit.js +447 -0
- package/cli/commands/init.js +288 -0
- package/cli/commands/validate.js +433 -0
- 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/cli/templates/.ai/yggdrasil/README.md +308 -0
- package/docs/AESTHETICS.md +168 -0
- package/docs/PHASE-6-PLAN.md +456 -0
- package/docs/PRIMEFLEX-POLICY.md +737 -0
- package/package.json +6 -1
- package/docs/Fixes.md +0 -258
- package/docs/archive/README.md +0 -27
- package/docs/archive/SEMANTIC-MIGRATION-PLAN.md +0 -177
- package/docs/archive/YGGDRASIL_THEME.md +0 -264
- package/docs/archive/agentic_policy.md +0 -216
- package/docs/contrast-report.md +0 -9
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction Patterns Rule: Focus Management
|
|
3
|
+
*
|
|
4
|
+
* Validates proper focus management in Dialogs, Modals, and form flows.
|
|
5
|
+
* Ensures keyboard navigation works correctly and focus is trapped/returned appropriately.
|
|
6
|
+
*
|
|
7
|
+
* Phase 6 - Interaction Patterns Agent
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'interaction-patterns/focus-management',
|
|
12
|
+
description: 'Validate Dialog/Modal focus patterns and keyboard navigation',
|
|
13
|
+
category: 'interaction-patterns',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
documentation: 'https://github.com/lifeonlars/prime-yggdrasil/blob/master/.ai/agents/interaction-patterns.md#5-focus-management',
|
|
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 Dialog components for focus management
|
|
26
|
+
const dialogRegex = /<Dialog[^>]*>/g;
|
|
27
|
+
let match;
|
|
28
|
+
|
|
29
|
+
while ((match = dialogRegex.exec(fileContent)) !== null) {
|
|
30
|
+
const dialogStart = match.index;
|
|
31
|
+
const dialogEnd = fileContent.indexOf('</Dialog>', dialogStart);
|
|
32
|
+
|
|
33
|
+
if (dialogEnd === -1) continue;
|
|
34
|
+
|
|
35
|
+
const dialogContent = fileContent.substring(dialogStart, dialogEnd);
|
|
36
|
+
const line = this.getLineNumber(fileContent, dialogStart);
|
|
37
|
+
|
|
38
|
+
// Check 1: Dialog should have modal prop (focus trap)
|
|
39
|
+
if (!dialogContent.includes('modal')) {
|
|
40
|
+
violations.push({
|
|
41
|
+
line,
|
|
42
|
+
column: 1,
|
|
43
|
+
message: 'Dialog missing modal prop. Add modal={true} to trap focus within dialog.',
|
|
44
|
+
severity: 'warning',
|
|
45
|
+
rule: this.name,
|
|
46
|
+
suggestion: '<Dialog visible={visible} onHide={onHide} modal>',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check 2: First interactive element should have autoFocus
|
|
51
|
+
const hasAutoFocus = /autoFocus/.test(dialogContent);
|
|
52
|
+
const hasInteractiveElement = /(<InputText|<Button|<Dropdown|<Calendar|<Checkbox)/.test(dialogContent);
|
|
53
|
+
|
|
54
|
+
if (hasInteractiveElement && !hasAutoFocus) {
|
|
55
|
+
violations.push({
|
|
56
|
+
line,
|
|
57
|
+
column: 1,
|
|
58
|
+
message: 'Dialog first interactive element should have autoFocus for keyboard navigation.',
|
|
59
|
+
severity: 'info',
|
|
60
|
+
rule: this.name,
|
|
61
|
+
suggestion: '<InputText autoFocus /> // First field in dialog',
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check 3: Dialog should have onHide handler (ESC key support)
|
|
66
|
+
if (!dialogContent.includes('onHide')) {
|
|
67
|
+
violations.push({
|
|
68
|
+
line,
|
|
69
|
+
column: 1,
|
|
70
|
+
message: 'Dialog missing onHide handler. Required for ESC key support.',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
rule: this.name,
|
|
73
|
+
suggestion: '<Dialog visible={visible} onHide={() => setVisible(false)}>',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Pattern 2: Check for interactive divs without keyboard handlers
|
|
79
|
+
const interactiveDivRegex = /<div[^>]*onClick[^>]*>/g;
|
|
80
|
+
|
|
81
|
+
while ((match = interactiveDivRegex.exec(fileContent)) !== null) {
|
|
82
|
+
const divTag = match[0];
|
|
83
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
84
|
+
|
|
85
|
+
// Check if div has keyboard handlers
|
|
86
|
+
const hasKeyboard = divTag.includes('onKeyDown') || divTag.includes('onKeyPress');
|
|
87
|
+
const hasRole = divTag.includes('role="button"') || divTag.includes('role=\\"button\\"');
|
|
88
|
+
const hasTabIndex = divTag.includes('tabIndex');
|
|
89
|
+
|
|
90
|
+
if (!hasKeyboard || !hasRole || !hasTabIndex) {
|
|
91
|
+
violations.push({
|
|
92
|
+
line,
|
|
93
|
+
column: 1,
|
|
94
|
+
message: 'Interactive div must have role="button", tabIndex, and keyboard handlers (onKeyDown).',
|
|
95
|
+
severity: 'error',
|
|
96
|
+
rule: this.name,
|
|
97
|
+
suggestion: this.getKeyboardHandlerSuggestion(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pattern 3: Check form fields for logical tab order
|
|
103
|
+
const formRegex = /<form[^>]*>/g;
|
|
104
|
+
|
|
105
|
+
while ((match = formRegex.exec(fileContent)) !== null) {
|
|
106
|
+
const formStart = match.index;
|
|
107
|
+
const formEnd = fileContent.indexOf('</form>', formStart);
|
|
108
|
+
|
|
109
|
+
if (formEnd === -1) continue;
|
|
110
|
+
|
|
111
|
+
const formContent = fileContent.substring(formStart, formEnd);
|
|
112
|
+
const line = this.getLineNumber(fileContent, formStart);
|
|
113
|
+
|
|
114
|
+
// Check for explicit tabIndex that might break natural order
|
|
115
|
+
const explicitTabIndex = /tabIndex=\{?(\d+)\}?/.exec(formContent);
|
|
116
|
+
|
|
117
|
+
if (explicitTabIndex && parseInt(explicitTabIndex[1]) > 0) {
|
|
118
|
+
violations.push({
|
|
119
|
+
line: line + this.getLineNumber(formContent, explicitTabIndex.index),
|
|
120
|
+
column: 1,
|
|
121
|
+
message: 'Avoid positive tabIndex values. Use natural DOM order or tabIndex={0} for custom elements.',
|
|
122
|
+
severity: 'warning',
|
|
123
|
+
rule: this.name,
|
|
124
|
+
suggestion: 'Remove tabIndex or use tabIndex={0} for same-level focus order.',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pattern 4: Check for custom focus styles
|
|
130
|
+
const hasFocusStyles = /:focus/.test(fileContent) || /focus-visible/.test(fileContent);
|
|
131
|
+
const hasInteractiveComponents = /<(Button|InputText|Dropdown|Calendar)/.test(fileContent);
|
|
132
|
+
|
|
133
|
+
if (hasInteractiveComponents && fileContent.includes('outline: none') && !hasFocusStyles) {
|
|
134
|
+
const line = this.getLineNumber(fileContent, fileContent.indexOf('outline: none'));
|
|
135
|
+
|
|
136
|
+
violations.push({
|
|
137
|
+
line,
|
|
138
|
+
column: 1,
|
|
139
|
+
message: 'Removing outline without custom focus styles breaks keyboard navigation visibility.',
|
|
140
|
+
severity: 'error',
|
|
141
|
+
rule: this.name,
|
|
142
|
+
suggestion: 'Replace with: boxShadow: "0 0 0 2px var(--border-state-focus)"',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return violations;
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
getKeyboardHandlerSuggestion() {
|
|
150
|
+
return `
|
|
151
|
+
<div
|
|
152
|
+
role="button"
|
|
153
|
+
tabIndex={0}
|
|
154
|
+
onClick={handleClick}
|
|
155
|
+
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && handleClick()}
|
|
156
|
+
>
|
|
157
|
+
Click me
|
|
158
|
+
</div>`;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
getLineNumber(content, index) {
|
|
162
|
+
return content.substring(0, index).split('\n').length;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
autofix(fileContent, violation) {
|
|
166
|
+
if (violation.message.includes('Dialog missing modal prop')) {
|
|
167
|
+
const dialogRegex = /<Dialog([^>]*)>/;
|
|
168
|
+
const match = dialogRegex.exec(fileContent);
|
|
169
|
+
|
|
170
|
+
if (match) {
|
|
171
|
+
const dialogTag = match[0];
|
|
172
|
+
const fixedTag = dialogTag.replace('<Dialog', '<Dialog modal');
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
fixed: true,
|
|
176
|
+
content: fileContent.replace(dialogTag, fixedTag),
|
|
177
|
+
message: 'Added modal prop to Dialog',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
fixed: false,
|
|
184
|
+
message: 'Manual fix required. See suggestion in violation message.',
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction Patterns Rule: Generic Copy
|
|
3
|
+
*
|
|
4
|
+
* Enforces specific, action-oriented button labels and copy instead of generic terms.
|
|
5
|
+
* Aligns with AESTHETICS.md: "Be specific, be concise, be functional"
|
|
6
|
+
*
|
|
7
|
+
* Phase 6 - Interaction Patterns Agent
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'interaction-patterns/generic-copy',
|
|
12
|
+
description: 'Enforce specific button labels over generic ones ("Save Changes" not "Submit")',
|
|
13
|
+
category: 'interaction-patterns',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
documentation: 'https://github.com/lifeonlars/prime-yggdrasil/blob/master/.ai/agents/interaction-patterns.md#7-copy-tone--content',
|
|
16
|
+
|
|
17
|
+
// Generic terms to avoid
|
|
18
|
+
genericTerms: {
|
|
19
|
+
// Button labels
|
|
20
|
+
'Submit': 'Use specific action like "Save Changes", "Create Account", "Send Message"',
|
|
21
|
+
'OK': 'Use specific action like "Confirm Delete", "Apply Settings", "Close"',
|
|
22
|
+
'Cancel': 'OK in most cases, but consider "Keep Editing", "Go Back" for clarity',
|
|
23
|
+
'Continue': 'Use specific action like "Next: Payment", "Proceed to Checkout"',
|
|
24
|
+
'Done': 'Use specific action like "Finish Setup", "Complete"',
|
|
25
|
+
'Click here': 'Use descriptive link text like "View documentation", "Learn more about tokens"',
|
|
26
|
+
'Click': 'Avoid instructional copy - buttons are self-evident',
|
|
27
|
+
|
|
28
|
+
// Messages
|
|
29
|
+
'Success!': 'Be specific: "Settings saved", "User created", "Email sent"',
|
|
30
|
+
'Error': 'Explain what failed: "Unable to save. Try again.", "Connection failed."',
|
|
31
|
+
'Warning': 'Be specific about the warning: "Unsaved changes will be lost"',
|
|
32
|
+
'Oops!': 'Avoid casual tone - be clear and pragmatic',
|
|
33
|
+
'Something went wrong': 'Explain the issue: "Unable to connect to server", "Invalid format"',
|
|
34
|
+
'Loading...': 'Be specific: "Loading users...", "Saving changes...", "Processing..."',
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
validate(fileContent, filePath) {
|
|
38
|
+
const violations = [];
|
|
39
|
+
|
|
40
|
+
// Skip non-React/TSX files
|
|
41
|
+
if (!filePath.match(/\.(jsx|tsx|js|ts)$/)) {
|
|
42
|
+
return violations;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Pattern 1: Check Button component labels
|
|
46
|
+
const buttonLabelRegex = /<Button[^>]*label=["']([^"']+)["']/g;
|
|
47
|
+
let match;
|
|
48
|
+
|
|
49
|
+
while ((match = buttonLabelRegex.exec(fileContent)) !== null) {
|
|
50
|
+
const label = match[1];
|
|
51
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
52
|
+
|
|
53
|
+
if (this.genericTerms[label]) {
|
|
54
|
+
violations.push({
|
|
55
|
+
line,
|
|
56
|
+
column: 1,
|
|
57
|
+
message: `Generic button label "${label}". ${this.genericTerms[label]}`,
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
rule: this.name,
|
|
60
|
+
suggestion: this.getSuggestion(label),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Pattern 2: Check button element text content
|
|
66
|
+
const buttonTextRegex = /<button[^>]*>([^<]+)<\/button>/gi;
|
|
67
|
+
|
|
68
|
+
while ((match = buttonTextRegex.exec(fileContent)) !== null) {
|
|
69
|
+
const text = match[1].trim();
|
|
70
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
71
|
+
|
|
72
|
+
if (this.genericTerms[text]) {
|
|
73
|
+
violations.push({
|
|
74
|
+
line,
|
|
75
|
+
column: 1,
|
|
76
|
+
message: `Generic button text "${text}". ${this.genericTerms[text]}`,
|
|
77
|
+
severity: 'warning',
|
|
78
|
+
rule: this.name,
|
|
79
|
+
suggestion: this.getSuggestion(text),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Pattern 3: Check Message/Toast content
|
|
85
|
+
const messagePatterns = [
|
|
86
|
+
/severity=["']success["'][^>]*text=["']([^"']+)["']/g,
|
|
87
|
+
/severity=["']error["'][^>]*text=["']([^"']+)["']/g,
|
|
88
|
+
/severity=["']warn["'][^>]*text=["']([^"']+)["']/g,
|
|
89
|
+
/summary=["']([^"']+)["']/g,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
messagePatterns.forEach(pattern => {
|
|
93
|
+
while ((match = pattern.exec(fileContent)) !== null) {
|
|
94
|
+
const text = match[1];
|
|
95
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
96
|
+
|
|
97
|
+
if (this.genericTerms[text]) {
|
|
98
|
+
violations.push({
|
|
99
|
+
line,
|
|
100
|
+
column: 1,
|
|
101
|
+
message: `Generic message "${text}". ${this.genericTerms[text]}`,
|
|
102
|
+
severity: 'warning',
|
|
103
|
+
rule: this.name,
|
|
104
|
+
suggestion: this.getSuggestion(text),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check for emoji/fluffy language
|
|
109
|
+
if (/[🎉😅❤️👍✨]/.test(text)) {
|
|
110
|
+
violations.push({
|
|
111
|
+
line,
|
|
112
|
+
column: 1,
|
|
113
|
+
message: `Avoid emoji in UI copy. Keep tone clear and pragmatic.`,
|
|
114
|
+
severity: 'warning',
|
|
115
|
+
rule: this.name,
|
|
116
|
+
suggestion: `Remove emoji from: "${text}"`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for casual/marketing language
|
|
121
|
+
const casualPhrases = [
|
|
122
|
+
/let's/i,
|
|
123
|
+
/awesome/i,
|
|
124
|
+
/amazing/i,
|
|
125
|
+
/yay/i,
|
|
126
|
+
/congrats/i,
|
|
127
|
+
/you're all set/i,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
casualPhrases.forEach(phrase => {
|
|
131
|
+
if (phrase.test(text)) {
|
|
132
|
+
violations.push({
|
|
133
|
+
line,
|
|
134
|
+
column: 1,
|
|
135
|
+
message: `Avoid casual/marketing language in UI copy: "${text}"`,
|
|
136
|
+
severity: 'warning',
|
|
137
|
+
rule: this.name,
|
|
138
|
+
suggestion: 'Use clear, functional copy. See docs/AESTHETICS.md for examples.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Pattern 4: Check link text
|
|
146
|
+
const linkTextRegex = /<a[^>]*>([^<]+)<\/a>/gi;
|
|
147
|
+
|
|
148
|
+
while ((match = linkTextRegex.exec(fileContent)) !== null) {
|
|
149
|
+
const text = match[1].trim();
|
|
150
|
+
const line = this.getLineNumber(fileContent, match.index);
|
|
151
|
+
|
|
152
|
+
if (this.genericTerms[text]) {
|
|
153
|
+
violations.push({
|
|
154
|
+
line,
|
|
155
|
+
column: 1,
|
|
156
|
+
message: `Generic link text "${text}". ${this.genericTerms[text]}`,
|
|
157
|
+
severity: 'warning',
|
|
158
|
+
rule: this.name,
|
|
159
|
+
suggestion: 'Use descriptive link text that explains the destination.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return violations;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
getSuggestion(genericTerm) {
|
|
168
|
+
const examples = {
|
|
169
|
+
'Submit': 'Examples: "Save Changes", "Create Project", "Send Email"',
|
|
170
|
+
'OK': 'Examples: "Confirm", "Apply", "Close Dialog"',
|
|
171
|
+
'Continue': 'Examples: "Next: Payment", "Proceed to Review"',
|
|
172
|
+
'Success!': 'Examples: "Settings saved", "User created successfully"',
|
|
173
|
+
'Error': 'Examples: "Unable to save. Try again.", "Invalid email format."',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
return examples[genericTerm] || 'Use specific, action-oriented copy.';
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
getLineNumber(content, index) {
|
|
180
|
+
return content.substring(0, index).split('\n').length;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
autofix(fileContent, violation) {
|
|
184
|
+
// No automatic fix for copy - requires human judgment
|
|
185
|
+
return {
|
|
186
|
+
fixed: false,
|
|
187
|
+
message: 'Manual fix required. Replace generic copy with specific, action-oriented text.',
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interaction Patterns Rules
|
|
3
|
+
*
|
|
4
|
+
* Phase 6 - Behavioral consistency enforcement
|
|
5
|
+
*
|
|
6
|
+
* All rules enforce patterns from .ai/agents/interaction-patterns.md
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import stateCompleteness from './state-completeness.js';
|
|
10
|
+
import genericCopy from './generic-copy.js';
|
|
11
|
+
import focusManagement from './focus-management.js';
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
'interaction-patterns/state-completeness': stateCompleteness,
|
|
15
|
+
'interaction-patterns/generic-copy': genericCopy,
|
|
16
|
+
'interaction-patterns/focus-management': focusManagement,
|
|
17
|
+
};
|