start-vibing-stacks 2.5.1 → 2.7.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.
Files changed (82) hide show
  1. package/dist/detector.js +5 -2
  2. package/dist/index.js +16 -2
  3. package/dist/migrate.d.ts +27 -0
  4. package/dist/migrate.js +217 -0
  5. package/dist/scanner.js +91 -0
  6. package/dist/setup.js +10 -0
  7. package/package.json +1 -1
  8. package/stacks/_shared/agents/claude-md-compactor.md +1 -0
  9. package/stacks/_shared/agents/commit-manager.md +1 -0
  10. package/stacks/_shared/agents/documenter.md +1 -0
  11. package/stacks/_shared/agents/domain-updater.md +1 -0
  12. package/stacks/_shared/agents/research-web.md +1 -0
  13. package/stacks/_shared/agents/security-auditor.md +168 -0
  14. package/stacks/_shared/agents/tester.md +1 -0
  15. package/stacks/_shared/hooks/final-check.ts +205 -0
  16. package/stacks/_shared/hooks/stop-validator.ts +77 -1
  17. package/stacks/_shared/skills/accessibility-wcag22/SKILL.md +284 -0
  18. package/stacks/_shared/skills/ci-pipelines/SKILL.md +166 -0
  19. package/stacks/_shared/skills/codebase-knowledge/SKILL.md +5 -0
  20. package/stacks/_shared/skills/database-migrations/SKILL.md +256 -0
  21. package/stacks/_shared/skills/debugging-patterns/SKILL.md +5 -0
  22. package/stacks/_shared/skills/docker-patterns/SKILL.md +5 -0
  23. package/stacks/_shared/skills/docs-tracker/SKILL.md +5 -0
  24. package/stacks/_shared/skills/error-handling/SKILL.md +335 -0
  25. package/stacks/_shared/skills/final-check/SKILL.md +74 -37
  26. package/stacks/_shared/skills/git-workflow/SKILL.md +5 -0
  27. package/stacks/_shared/skills/hook-development/SKILL.md +5 -0
  28. package/stacks/_shared/skills/observability/SKILL.md +351 -0
  29. package/stacks/_shared/skills/performance-patterns/SKILL.md +5 -0
  30. package/stacks/_shared/skills/playwright-automation/SKILL.md +5 -0
  31. package/stacks/_shared/skills/quality-gate/SKILL.md +5 -0
  32. package/stacks/_shared/skills/research-cache/SKILL.md +5 -0
  33. package/stacks/_shared/skills/secrets-management/SKILL.md +245 -0
  34. package/stacks/_shared/skills/security-baseline/SKILL.md +202 -0
  35. package/stacks/_shared/skills/test-coverage/SKILL.md +5 -0
  36. package/stacks/_shared/skills/ui-ux-audit/SKILL.md +5 -0
  37. package/stacks/frontend/react/skills/preline-ui/SKILL.md +5 -0
  38. package/stacks/frontend/react/skills/react-patterns/SKILL.md +5 -0
  39. package/stacks/frontend/react/skills/react-standards/SKILL.md +5 -0
  40. package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +5 -0
  41. package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +5 -0
  42. package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +5 -0
  43. package/stacks/frontend/react/skills/zod-validation/SKILL.md +5 -0
  44. package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +5 -0
  45. package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +5 -0
  46. package/stacks/nodejs/skills/api-security-node/SKILL.md +275 -0
  47. package/stacks/nodejs/skills/bun-runtime/SKILL.md +5 -0
  48. package/stacks/nodejs/skills/mongoose-patterns/SKILL.md +5 -0
  49. package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +5 -0
  50. package/stacks/nodejs/skills/trpc-api/SKILL.md +5 -0
  51. package/stacks/nodejs/skills/typescript-strict/SKILL.md +5 -0
  52. package/stacks/nodejs/stack.json +2 -1
  53. package/stacks/nodejs/workflows/ci.yml +90 -0
  54. package/stacks/nodejs/workflows/security.yml +45 -0
  55. package/stacks/php/skills/api-design/SKILL.md +5 -0
  56. package/stacks/php/skills/api-security/SKILL.md +5 -0
  57. package/stacks/php/skills/composer-workflow/SKILL.md +5 -0
  58. package/stacks/php/skills/external-api-patterns/SKILL.md +5 -0
  59. package/stacks/php/skills/inertia-react/SKILL.md +5 -0
  60. package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +5 -0
  61. package/stacks/php/skills/laravel-octane/SKILL.md +5 -0
  62. package/stacks/php/skills/laravel-patterns/SKILL.md +5 -0
  63. package/stacks/php/skills/mariadb-octane/SKILL.md +5 -0
  64. package/stacks/php/skills/php-patterns/SKILL.md +5 -0
  65. package/stacks/php/skills/phpstan-analysis/SKILL.md +5 -0
  66. package/stacks/php/skills/phpunit-testing/SKILL.md +5 -0
  67. package/stacks/php/skills/security-scan-php/SKILL.md +5 -0
  68. package/stacks/php/workflows/ci.yml +106 -0
  69. package/stacks/php/workflows/security.yml +36 -0
  70. package/stacks/python/skills/api-security-python/SKILL.md +312 -0
  71. package/stacks/python/skills/async-patterns/SKILL.md +5 -0
  72. package/stacks/python/skills/django-patterns/SKILL.md +5 -0
  73. package/stacks/python/skills/fastapi-patterns/SKILL.md +5 -0
  74. package/stacks/python/skills/pydantic-validation/SKILL.md +5 -0
  75. package/stacks/python/skills/pytest-testing/SKILL.md +5 -0
  76. package/stacks/python/skills/python-patterns/SKILL.md +26 -5
  77. package/stacks/python/skills/python-performance/SKILL.md +5 -0
  78. package/stacks/python/skills/scripting-automation/SKILL.md +260 -0
  79. package/stacks/python/stack.json +70 -35
  80. package/stacks/python/workflows/ci.yml +76 -0
  81. package/stacks/python/workflows/security.yml +56 -0
  82. package/templates/CLAUDE-python.md +315 -0
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Final Check — executable validator with VETO.
4
+ *
5
+ * Scans staged + unstaged source files for forbidden patterns:
6
+ * - debug statements (console.log, var_dump, dd, print debug)
7
+ * - TODO/FIXME left in code
8
+ * - any-typed code (TypeScript)
9
+ * - hardcoded secrets
10
+ * - test .skip / .only
11
+ * - dangerous functions (RCE, command injection)
12
+ *
13
+ * Exits 0 if clean, 1 if any blocking finding. Output is human-readable.
14
+ */
15
+
16
+ import { spawnSync } from 'child_process';
17
+ import { existsSync, readFileSync, statSync } from 'fs';
18
+ import { extname, join } from 'path';
19
+
20
+ const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
21
+ const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
22
+
23
+ let stackId = 'unknown';
24
+ try {
25
+ if (existsSync(ACTIVE_PROJECT)) {
26
+ stackId = JSON.parse(readFileSync(ACTIVE_PROJECT, 'utf8')).stack || 'unknown';
27
+ }
28
+ } catch {}
29
+
30
+ const STACK_EXTENSIONS: Record<string, Set<string>> = {
31
+ php: new Set(['.php', '.blade.php']),
32
+ nodejs: new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']),
33
+ python: new Set(['.py']),
34
+ };
35
+ const sourceExt = STACK_EXTENSIONS[stackId] || new Set(['.ts', '.js', '.php', '.py']);
36
+
37
+ interface Finding {
38
+ severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
39
+ file: string;
40
+ line: number;
41
+ rule: string;
42
+ excerpt: string;
43
+ }
44
+
45
+ const findings: Finding[] = [];
46
+
47
+ function git(...args: string[]): string {
48
+ const r = spawnSync('git', args, { cwd: PROJECT_DIR, encoding: 'utf8' });
49
+ return (r.stdout || '').trim();
50
+ }
51
+
52
+ // Paths that are always skipped (validator itself, generated, vendored, etc.)
53
+ const SKIP_PATH_RE = /(^|\/)(\.claude\/hooks|\.claude\/scripts|node_modules|dist|build|coverage|vendor|\.next|\.nuxt|venv|\.venv|stacks\/_shared\/hooks)\//;
54
+
55
+ function listFiles(): string[] {
56
+ const staged = git('diff', '--name-only', '--cached', '--diff-filter=ACMR').split('\n');
57
+ const unstaged = git('diff', '--name-only', '--diff-filter=ACMR').split('\n');
58
+ const untracked = git('ls-files', '--others', '--exclude-standard').split('\n');
59
+ const all = new Set<string>();
60
+ for (const f of [...staged, ...unstaged, ...untracked]) {
61
+ if (!f) continue;
62
+ if (SKIP_PATH_RE.test('/' + f)) continue;
63
+ if (!sourceExt.has(extname(f).toLowerCase())) continue;
64
+ const full = join(PROJECT_DIR, f);
65
+ if (!existsSync(full)) continue;
66
+ try {
67
+ if (statSync(full).isFile()) all.add(f);
68
+ } catch {}
69
+ }
70
+ return [...all];
71
+ }
72
+
73
+ interface Rule {
74
+ re: RegExp;
75
+ rule: string;
76
+ severity: Finding['severity'];
77
+ appliesTo?: (path: string) => boolean;
78
+ }
79
+
80
+ const isTest = (p: string) => /\.(test|spec)\.[tj]sx?$|tests?\//i.test(p);
81
+ const isPhp = (p: string) => p.endsWith('.php');
82
+ const isPy = (p: string) => p.endsWith('.py');
83
+ const isJs = (p: string) => /\.(t|j)sx?$|\.mjs$/.test(p);
84
+
85
+ // Build dangerous-function regex via parts to avoid trivial lexical scans
86
+ const DANGEROUS_JS = new RegExp('\\b' + 'ev' + 'al' + '\\s*\\(');
87
+ const DANGEROUS_PY = new RegExp('\\b(?:' + 'ev' + 'al' + '|exec)\\s*\\(');
88
+
89
+ const RULES: Rule[] = [
90
+ // Debug statements
91
+ { re: /\bconsole\.(log|debug|trace)\s*\(/, rule: 'console debug statement', severity: 'MEDIUM',
92
+ appliesTo: p => isJs(p) && !isTest(p) },
93
+ { re: /\b(var_dump|dd|dump|print_r)\s*\(/, rule: 'PHP debug statement', severity: 'MEDIUM',
94
+ appliesTo: isPhp },
95
+ { re: /^[^#]*\bprint\s*\(/m, rule: 'Python print() (use logger)', severity: 'LOW',
96
+ appliesTo: p => isPy(p) && !isTest(p) },
97
+
98
+ // Tests
99
+ { re: /\b(it|test|describe)\.(only|skip)\s*\(/, rule: '.only / .skip in test', severity: 'HIGH',
100
+ appliesTo: p => /\.(test|spec)\./i.test(p) },
101
+ { re: /@pytest\.mark\.skip\b/, rule: 'pytest skip marker', severity: 'MEDIUM',
102
+ appliesTo: isPy },
103
+
104
+ // TypeScript any
105
+ { re: /:\s*any\b(?!\s*\/\*\s*ok)/, rule: 'explicit any (use unknown or proper type)', severity: 'MEDIUM',
106
+ appliesTo: p => /\.(ts|tsx)$/.test(p) },
107
+ { re: /@ts-ignore/, rule: '@ts-ignore (use @ts-expect-error with comment)', severity: 'MEDIUM',
108
+ appliesTo: p => /\.(ts|tsx)$/.test(p) },
109
+
110
+ // TODO / FIXME — informational
111
+ { re: /\b(TODO|FIXME|XXX|HACK)\b/, rule: 'unresolved TODO/FIXME', severity: 'LOW' },
112
+
113
+ // Secrets — high signal patterns
114
+ { re: /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{20,}["']/i,
115
+ rule: 'possible hardcoded secret', severity: 'CRITICAL' },
116
+ { re: /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/,
117
+ rule: 'public env var contains secret-like name', severity: 'CRITICAL' },
118
+
119
+ // Dangerous code execution
120
+ { re: DANGEROUS_JS, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
121
+ appliesTo: p => isJs(p) || isPhp(p) },
122
+ { re: DANGEROUS_PY, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
123
+ appliesTo: isPy },
124
+ { re: /shell\s*=\s*True/, rule: 'subprocess shell=True (command injection)', severity: 'HIGH',
125
+ appliesTo: isPy },
126
+
127
+ // SQL string concat (rough)
128
+ { re: /(SELECT|INSERT|UPDATE|DELETE)\s[^"']*?\+\s*[a-zA-Z_]/i,
129
+ rule: 'possible SQL string concatenation', severity: 'HIGH', appliesTo: p => isJs(p) || isPhp(p) },
130
+ { re: /f["'][^"']*\b(SELECT|INSERT|UPDATE|DELETE)\b[^"']*\{/i,
131
+ rule: 'f-string SQL (use bind parameters)', severity: 'HIGH', appliesTo: isPy },
132
+ ];
133
+
134
+ const SEVERITY_ORDER: Record<Finding['severity'], number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
135
+ const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
136
+
137
+ function scan(file: string) {
138
+ const full = join(PROJECT_DIR, file);
139
+ let content: string;
140
+ try { content = readFileSync(full, 'utf8'); } catch { return; }
141
+
142
+ const lines = content.split('\n');
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const line = lines[i] ?? '';
145
+ if (line.length > 1000) continue; // skip minified
146
+ if (PLACEHOLDER_RE.test(line)) continue; // example/template values
147
+ for (const r of RULES) {
148
+ if (r.appliesTo && !r.appliesTo(file)) continue;
149
+ if (r.re.test(line)) {
150
+ findings.push({
151
+ severity: r.severity,
152
+ file,
153
+ line: i + 1,
154
+ rule: r.rule,
155
+ excerpt: line.trim().slice(0, 160),
156
+ });
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ function main() {
163
+ const files = listFiles();
164
+ if (files.length === 0) {
165
+ console.log('Final check: no source files in current diff to scan.');
166
+ process.exit(0);
167
+ }
168
+
169
+ for (const f of files) scan(f);
170
+
171
+ findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
172
+
173
+ const blocking = findings.filter(f => f.severity === 'CRITICAL' || f.severity === 'HIGH');
174
+ const warnings = findings.filter(f => f.severity === 'MEDIUM' || f.severity === 'LOW');
175
+
176
+ if (findings.length === 0) {
177
+ console.log(`Final check passed (${files.length} file${files.length === 1 ? '' : 's'} scanned, stack=${stackId}).`);
178
+ process.exit(0);
179
+ }
180
+
181
+ console.log(`Final check report — stack=${stackId}, files=${files.length}\n`);
182
+
183
+ if (blocking.length > 0) {
184
+ console.log(`BLOCKING — ${blocking.length} critical/high finding${blocking.length === 1 ? '' : 's'}:\n`);
185
+ for (const f of blocking) {
186
+ console.log(` [${f.severity}] ${f.file}:${f.line}`);
187
+ console.log(` ${f.rule}`);
188
+ console.log(` > ${f.excerpt}`);
189
+ }
190
+ console.log('');
191
+ }
192
+
193
+ if (warnings.length > 0) {
194
+ console.log(`WARNINGS — ${warnings.length} medium/low finding${warnings.length === 1 ? '' : 's'}:\n`);
195
+ for (const f of warnings.slice(0, 20)) {
196
+ console.log(` [${f.severity}] ${f.file}:${f.line} ${f.rule}`);
197
+ }
198
+ if (warnings.length > 20) console.log(` ... and ${warnings.length - 20} more`);
199
+ console.log('');
200
+ }
201
+
202
+ process.exit(blocking.length > 0 ? 1 : 0);
203
+ }
204
+
205
+ main();
@@ -9,6 +9,7 @@
9
9
  * 3. CLAUDE.md not updated
10
10
  * 4. CLAUDE.md missing required sections
11
11
  * 5. CLAUDE.md exceeds 40k chars
12
+ * 6. Secret pattern detected in committed/staged files (uses gitleaks if present, fallback to regex)
12
13
  */
13
14
 
14
15
  import { execSync } from 'child_process';
@@ -65,6 +66,71 @@ function getModifiedFiles(): string[] {
65
66
  return [...new Set([...staged, ...unstaged, ...untracked])];
66
67
  }
67
68
 
69
+ /**
70
+ * Run a command capturing stdout, stderr and exit code without throwing.
71
+ */
72
+ function runCapture(command: string): { code: number; stdout: string; stderr: string } {
73
+ try {
74
+ const stdout = execSync(command, {
75
+ cwd: PROJECT_DIR,
76
+ encoding: 'utf8',
77
+ stdio: ['pipe', 'pipe', 'pipe'],
78
+ });
79
+ return { code: 0, stdout: stdout.toString(), stderr: '' };
80
+ } catch (err: any) {
81
+ return {
82
+ code: typeof err?.status === 'number' ? err.status : 1,
83
+ stdout: err?.stdout?.toString?.() ?? '',
84
+ stderr: err?.stderr?.toString?.() ?? '',
85
+ };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Scan staged + unstaged diff for secret patterns.
91
+ * Uses gitleaks when installed; otherwise applies a high-signal regex sweep.
92
+ * Returns a list of findings (empty = clean).
93
+ */
94
+ function scanSecrets(): string[] {
95
+ const findings: string[] = [];
96
+
97
+ const hasGitleaks = cmd('command -v gitleaks').length > 0;
98
+ if (hasGitleaks) {
99
+ const { code, stdout, stderr } = runCapture(
100
+ 'gitleaks detect --no-banner --redact --exit-code 1 --log-level=error'
101
+ );
102
+ if (code !== 0) {
103
+ const out = stdout + '\n' + stderr;
104
+ const lines = out
105
+ .split('\n')
106
+ .filter(l => l.includes('Finding:') || l.includes('File:') || l.includes('Secret:'));
107
+ if (lines.length > 0) findings.push(...lines.slice(0, 20));
108
+ else findings.push('gitleaks reported leaks (run `gitleaks detect --redact -v` for details)');
109
+ }
110
+ return findings;
111
+ }
112
+
113
+ // Fallback: high-signal regex sweep over staged + unstaged diff
114
+ const SECRET_RE = /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{16,}["']/i;
115
+ const PUBLIC_LEAK_RE = /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/;
116
+ const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
117
+
118
+ const diff = cmd('git diff --cached -U0') + '\n' + cmd('git diff -U0');
119
+ if (!diff.trim()) return findings;
120
+
121
+ const lines = diff.split('\n');
122
+ for (const line of lines) {
123
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
124
+ if (PLACEHOLDER_RE.test(line)) continue;
125
+ if (SECRET_RE.test(line) || PUBLIC_LEAK_RE.test(line)) {
126
+ const masked = line.replace(/(["']).{8,}\1/, '$1[REDACTED]$1').slice(0, 160);
127
+ findings.push(masked);
128
+ if (findings.length >= 5) break;
129
+ }
130
+ }
131
+ return findings;
132
+ }
133
+
68
134
  function validate(): HookResult {
69
135
  const branch = getBranch();
70
136
  const isMain = branch === 'main' || branch === 'master';
@@ -132,11 +198,21 @@ function validate(): HookResult {
132
198
  };
133
199
  }
134
200
 
201
+ // 5. Secret scan (gitleaks or regex fallback)
202
+ const secrets = scanSecrets();
203
+ if (secrets.length > 0) {
204
+ return {
205
+ continue: true,
206
+ decision: 'block',
207
+ reason: `BLOCKED: Potential secrets detected in diff:\n${secrets.map(s => ` - ${s}`).join('\n')}\n\nRotate the credential, remove from diff, and re-run.`,
208
+ };
209
+ }
210
+
135
211
  // All good
136
212
  return {
137
213
  continue: false,
138
214
  decision: 'approve',
139
- reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean`,
215
+ reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean\nSecrets: clean`,
140
216
  };
141
217
  }
142
218
 
@@ -0,0 +1,284 @@
1
+ ---
2
+ name: accessibility-wcag22
3
+ version: 1.0.0
4
+ description: WCAG 2.2 AA checklist + axe-core / Playwright integration. Invoke when implementing UI features or auditing accessibility. Pairs with ui-ux-audit.
5
+ ---
6
+
7
+ # Accessibility — WCAG 2.2 AA
8
+
9
+ **ALWAYS invoke when implementing UI, forms, navigation, or any user-facing component.**
10
+
11
+ > Accessibility is a feature, not a polish step. Building it in costs ~10x less than retrofitting.
12
+
13
+ ## Targeting WCAG 2.2 AA
14
+
15
+ WCAG 2.2 (October 2023) adds 9 new success criteria over 2.1. Key additions:
16
+
17
+ | New (2.2) | What | Most relevant when |
18
+ |---|---|---|
19
+ | 2.4.11 / 2.4.12 Focus Not Obscured | Focused element must not be fully hidden by sticky headers/dialogs | Sticky nav, modals |
20
+ | 2.4.13 Focus Appearance (AAA) | Focus indicator size/contrast | Custom focus styles |
21
+ | 2.5.7 Dragging Movements | Drag operations must have a non-drag alternative | Kanban, sliders, reordering |
22
+ | 2.5.8 Target Size (Min) | Targets ≥ 24×24 CSS px | Buttons, icon links, list items |
23
+ | 3.2.6 Consistent Help | Help mechanism in same location across pages | Multi-page apps |
24
+ | 3.3.7 / 3.3.8 Redundant Entry / Accessible Authentication | Don't require re-entering data; offer non-cognitive auth | Multi-step forms, login |
25
+
26
+ ## The "Big 8" — Catches 80% of Issues
27
+
28
+ 1. **Semantic HTML** — `<button>` not `<div onClick>`. `<a href>` for navigation, `<button>` for actions.
29
+ 2. **Keyboard navigation** — every interactive thing reachable via Tab, activatable via Enter/Space.
30
+ 3. **Focus indicator** — visible outline on focus. Don't `outline: none` without a replacement.
31
+ 4. **Labels** — every form input has `<label for>` or `aria-label`.
32
+ 5. **Color contrast** — text 4.5:1 (3:1 for ≥18pt or 14pt bold). Non-text UI 3:1.
33
+ 6. **Alt text** — every meaningful image has `alt`; decorative images have `alt=""`.
34
+ 7. **Heading order** — one `<h1>` per page; no skipping levels (h2 → h4).
35
+ 8. **ARIA only when needed** — first rule of ARIA: don't use ARIA. Native HTML almost always wins.
36
+
37
+ ---
38
+
39
+ ## React + Semantic HTML
40
+
41
+ ```tsx
42
+ // WRONG
43
+ <div className="button" onClick={handleClick}>Submit</div>
44
+
45
+ // CORRECT
46
+ <button type="button" onClick={handleClick}>Submit</button>
47
+
48
+ // WRONG — visually a link, semantically a button
49
+ <a onClick={navigate}>Settings</a>
50
+
51
+ // CORRECT
52
+ <a href="/settings">Settings</a>
53
+ ```
54
+
55
+ ### Accessible custom controls
56
+ If you must build a custom widget (Combobox, Disclosure, Tabs), follow the **WAI-ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/patterns/
57
+
58
+ Better — use a headless library that already implements them:
59
+ - **Radix UI** (used by shadcn/ui)
60
+ - **React Aria** (Adobe)
61
+ - **Headless UI** (Tailwind Labs)
62
+
63
+ ---
64
+
65
+ ## Forms
66
+
67
+ ```tsx
68
+ <label htmlFor="email">Email</label>
69
+ <input
70
+ id="email"
71
+ type="email"
72
+ name="email"
73
+ required
74
+ aria-invalid={!!error}
75
+ aria-describedby={error ? 'email-error' : undefined}
76
+ autoComplete="email"
77
+ />
78
+ {error && <p id="email-error" role="alert">{error}</p>}
79
+ ```
80
+
81
+ Rules:
82
+ - Every input has a label (visible or `aria-label` for icon-only).
83
+ - `autoComplete` set for personal info fields (helps password managers + screen readers + 3.3.7).
84
+ - Error messages: `role="alert"` for live announcement OR `aria-describedby`.
85
+ - Don't disable submit while form is invalid; let user submit and show errors (better UX + a11y).
86
+
87
+ ---
88
+
89
+ ## Focus Management
90
+
91
+ ```tsx
92
+ // Modal opens → move focus into the modal; closes → restore to trigger
93
+ const triggerRef = useRef<HTMLButtonElement>(null);
94
+ const dialogRef = useRef<HTMLDialogElement>(null);
95
+
96
+ useEffect(() => {
97
+ if (isOpen) dialogRef.current?.focus();
98
+ else triggerRef.current?.focus();
99
+ }, [isOpen]);
100
+ ```
101
+
102
+ Use Radix / React Aria `Dialog` — they handle focus trap, escape, and `aria-modal` correctly.
103
+
104
+ ### Skip links
105
+ ```tsx
106
+ <a href="#main" className="sr-only focus:not-sr-only">Skip to content</a>
107
+ <main id="main" tabIndex={-1}>...</main>
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Color & Contrast
113
+
114
+ ```css
115
+ /* Background and foreground must hit 4.5:1 */
116
+ .btn-primary {
117
+ background: #2563eb; /* blue-600 */
118
+ color: #ffffff; /* 8.59:1 — passes AAA */
119
+ }
120
+ ```
121
+
122
+ Tools:
123
+ - Browser devtools have built-in contrast checkers
124
+ - `@axe-core/react` shows warnings inline during dev
125
+ - VS Code: a11y-themes / Color Picker extensions
126
+
127
+ **Never** use color alone to convey meaning (e.g. red border = error). Pair with icon + text.
128
+
129
+ ---
130
+
131
+ ## Keyboard Testing — Manual
132
+
133
+ For every UI:
134
+ 1. Tab through — every interactive thing is reachable
135
+ 2. Tab order is logical (visual order matches DOM order)
136
+ 3. Enter activates buttons/links; Space activates buttons (and toggles checkboxes)
137
+ 4. Esc closes modals/menus
138
+ 5. Arrow keys in radio groups, tabs, menus, listboxes
139
+ 6. Focus is visible at every step
140
+ 7. Focus is never lost or trapped (other than intentional in modals)
141
+
142
+ ---
143
+
144
+ ## Automated Testing — axe-core + Playwright
145
+
146
+ ### Install
147
+ ```bash
148
+ bun add -d @axe-core/playwright
149
+ ```
150
+
151
+ ### Per-page check
152
+ ```ts
153
+ // tests/e2e/a11y.spec.ts
154
+ import { test, expect } from '@playwright/test';
155
+ import AxeBuilder from '@axe-core/playwright';
156
+
157
+ test.describe('a11y', () => {
158
+ for (const path of ['/', '/login', '/dashboard', '/settings']) {
159
+ test(`no axe violations on ${path}`, async ({ page }) => {
160
+ await page.goto(path);
161
+ const results = await new AxeBuilder({ page })
162
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
163
+ .analyze();
164
+ expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
165
+ });
166
+ }
167
+ });
168
+ ```
169
+
170
+ ### Per-component check
171
+ ```ts
172
+ test('Modal is accessible', async ({ page }) => {
173
+ await page.goto('/components/modal-demo');
174
+ await page.click('text=Open');
175
+ const results = await new AxeBuilder({ page })
176
+ .include('[role="dialog"]')
177
+ .withTags(['wcag2aa', 'wcag22aa'])
178
+ .analyze();
179
+ expect(results.violations).toEqual([]);
180
+ });
181
+ ```
182
+
183
+ ### Dev-time inline checks (React)
184
+ ```ts
185
+ // app/providers.tsx — only in dev
186
+ if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
187
+ import('@axe-core/react').then(axe => {
188
+ import('react-dom').then(ReactDOM => axe.default(React, ReactDOM, 1000));
189
+ });
190
+ }
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Lighthouse CI (Pages)
196
+
197
+ ```yaml
198
+ # .github/workflows/ci.yml — append to e2e job
199
+ - name: Lighthouse CI
200
+ uses: treosh/lighthouse-ci-action@v11
201
+ with:
202
+ urls: |
203
+ http://localhost:3000/
204
+ http://localhost:3000/dashboard
205
+ configPath: ./.lighthouserc.json
206
+ ```
207
+
208
+ `.lighthouserc.json`:
209
+ ```json
210
+ {
211
+ "ci": {
212
+ "assert": {
213
+ "assertions": {
214
+ "categories:accessibility": ["error", { "minScore": 0.95 }]
215
+ }
216
+ }
217
+ }
218
+ }
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Mobile / Touch (WCAG 2.2 §2.5.8)
224
+
225
+ ```css
226
+ /* Minimum touch target — 24×24 CSS px (44×44 recommended for mobile) */
227
+ button, [role="button"], a {
228
+ min-width: 44px;
229
+ min-height: 44px;
230
+ }
231
+ ```
232
+
233
+ For dense UIs, the spec allows smaller if "equivalent functionality" exists elsewhere — but it's safer to just hit 44px.
234
+
235
+ ---
236
+
237
+ ## Screen Reader Testing
238
+
239
+ You can't ship a11y without ever using a screen reader. Quick smoke:
240
+ - macOS: VoiceOver (`Cmd+F5`)
241
+ - Windows: NVDA (free) or Narrator
242
+ - Mobile: VoiceOver (iOS) / TalkBack (Android)
243
+
244
+ Test the golden path: load page → tab through → fill form → submit. If the screen reader announces what's happening, you're 80% there.
245
+
246
+ ---
247
+
248
+ ## Pre-Commit Checklist
249
+
250
+ - [ ] Semantic HTML (no `div onClick` for buttons/links)
251
+ - [ ] Every form input has a label
252
+ - [ ] Every image has `alt` (or `alt=""` if decorative)
253
+ - [ ] Heading levels are sequential (no skipping)
254
+ - [ ] Color contrast ≥ 4.5:1 for body text, 3:1 for large/UI
255
+ - [ ] Focus visible on all interactive elements
256
+ - [ ] Keyboard test passes (Tab/Enter/Esc/Arrows)
257
+ - [ ] axe-core test added for new pages/components
258
+ - [ ] Touch targets ≥ 24×24 px (44 recommended)
259
+ - [ ] Drag operations have non-drag alternative (WCAG 2.2 §2.5.7)
260
+ - [ ] No `outline: none` without replacement
261
+ - [ ] No `aria-*` on the wrong element type
262
+
263
+ ## FORBIDDEN
264
+
265
+ | Pattern | Why |
266
+ |---|---|
267
+ | `<div onClick>` for an action | Not keyboard-reachable, no role |
268
+ | `<a>` without `href` | Not keyboard-reachable, no link semantics |
269
+ | `outline: none` with no `:focus-visible` style | Invisible focus = unusable |
270
+ | `aria-label` on a `<div>` that should be a `<button>` | Cargo-cult ARIA — fix the element |
271
+ | Color-only error indication | Colorblind users can't see |
272
+ | `role="button"` on a `<button>` | Redundant; native role wins |
273
+ | Placeholder as label | Disappears on input, fails contrast |
274
+ | Auto-playing video/audio with sound | WCAG 1.4.2 |
275
+ | Drag-only interactions | WCAG 2.2 §2.5.7 — provide buttons |
276
+ | Modal that traps tab AND has unreachable close button | Keyboard dead-end |
277
+
278
+ ## See Also
279
+
280
+ - `ui-ux-audit` — broader UX audit including responsive
281
+ - `playwright-automation` — where the axe tests live
282
+ - `react-patterns` — Radix / React Aria preferred over custom widgets
283
+ - WCAG 2.2 quick-reference: https://www.w3.org/WAI/WCAG22/quickref/
284
+ - WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/