@diegovelasquezweb/a11y-engine 0.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.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * @file source-scanner.mjs
3
+ * @description Source code pattern scanner for accessibility issues not detectable by axe-core.
4
+ * Runs regex-based patterns from code-patterns.json against the project source tree
5
+ * and outputs structured findings compatible with the a11y pipeline.
6
+ */
7
+
8
+ import { readFileSync, readdirSync, statSync } from "node:fs";
9
+ import { join, relative, extname, resolve } from "node:path";
10
+ import { createHash } from "node:crypto";
11
+ import { fileURLToPath } from "node:url";
12
+ import { log, writeJson, getInternalPath } from "../core/utils.mjs";
13
+ import { ASSET_PATHS, loadAssetJson } from "../core/asset-loader.mjs";
14
+
15
+ const SKIP_DIRS = new Set([
16
+ "node_modules", ".git", "dist", "build", ".next", ".nuxt",
17
+ ".cache", "coverage", ".audit", "out", ".turbo", ".svelte-kit",
18
+ ".vercel", ".netlify", "public", "static",
19
+ "wp-includes", "wp-admin",
20
+ ]);
21
+
22
+ const SKIP_FILE_PATTERN = /\.min\.(js|css)$/i;
23
+
24
+ const SOURCE_BOUNDARIES = loadAssetJson(
25
+ ASSET_PATHS.remediation.sourceBoundaries,
26
+ "assets/remediation/source-boundaries.json",
27
+ );
28
+
29
+ function printUsage() {
30
+ log.info(`Usage:
31
+ node scripts/engine/source-scanner.mjs --project-dir <path> [options]
32
+
33
+ Options:
34
+ --project-dir <path> Path to the project source root to scan (required)
35
+ --output <path> Output JSON path (default: internal .audit/a11y-pattern-findings.json)
36
+ --only-pattern <id> Only run a specific pattern ID
37
+ -h, --help Show this help
38
+ `);
39
+ }
40
+
41
+ function parseArgs(argv) {
42
+ if (argv.includes("--help") || argv.includes("-h")) {
43
+ printUsage();
44
+ process.exit(0);
45
+ }
46
+
47
+ const args = {
48
+ projectDir: null,
49
+ framework: null,
50
+ output: getInternalPath("a11y-pattern-findings.json"),
51
+ onlyPattern: null,
52
+ };
53
+
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const key = argv[i];
56
+ const value = argv[i + 1];
57
+ if (!key.startsWith("--") || value === undefined) continue;
58
+ if (key === "--project-dir") args.projectDir = value;
59
+ if (key === "--framework") args.framework = value;
60
+ if (key === "--output") args.output = value;
61
+ if (key === "--only-pattern") args.onlyPattern = value;
62
+ i++;
63
+ }
64
+
65
+ if (!args.projectDir) throw new Error("Missing required --project-dir");
66
+ return args;
67
+ }
68
+
69
+ /**
70
+ * Extracts file extensions from glob patterns like "**\/*.tsx".
71
+ * @param {string[]} globs
72
+ * @returns {Set<string>}
73
+ */
74
+ export function parseExtensions(globs) {
75
+ const exts = new Set();
76
+ for (const glob of globs) {
77
+ const match = glob.trim().match(/\*\.(\w+)$/);
78
+ if (match) exts.add(`.${match[1]}`);
79
+ }
80
+ return exts;
81
+ }
82
+
83
+ /**
84
+ * Recursively walks a directory and collects files matching the given extensions.
85
+ * @param {string} dir
86
+ * @param {Set<string>} extensions
87
+ * @param {string[]} results
88
+ * @returns {string[]}
89
+ */
90
+ function walkFiles(dir, extensions, results = []) {
91
+ let entries;
92
+ try {
93
+ entries = readdirSync(dir, { withFileTypes: true });
94
+ } catch {
95
+ return results;
96
+ }
97
+
98
+ for (const entry of entries) {
99
+ if (entry.isDirectory()) {
100
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
101
+ walkFiles(join(dir, entry.name), extensions, results);
102
+ } else if (entry.isFile() && extensions.has(extname(entry.name)) && !SKIP_FILE_PATTERN.test(entry.name)) {
103
+ results.push(join(dir, entry.name));
104
+ }
105
+ }
106
+ return results;
107
+ }
108
+
109
+ /**
110
+ * Resolves the directories to scan based on framework source boundaries.
111
+ * Falls back to the full project dir if no boundaries are defined.
112
+ * @param {string|null} framework
113
+ * @param {string} projectDir
114
+ * @returns {string[]}
115
+ */
116
+ function resolveScanDirs(framework, projectDir) {
117
+ const boundaries = framework ? SOURCE_BOUNDARIES?.[framework] : null;
118
+ if (!boundaries) return [projectDir];
119
+
120
+ const allGlobs = [boundaries.components, boundaries.styles]
121
+ .filter(Boolean)
122
+ .flatMap((g) => g.split(",").map((s) => s.trim()));
123
+
124
+ const prefixes = new Set();
125
+ for (const glob of allGlobs) {
126
+ const prefix = glob.split(/[*?{]/)[0].replace(/\/$/, "");
127
+ if (prefix) prefixes.add(prefix);
128
+ }
129
+
130
+ const dirs = [...prefixes]
131
+ .map((p) => resolve(projectDir, p))
132
+ .filter((d) => { try { return statSync(d).isDirectory(); } catch { return false; } });
133
+
134
+ const deduped = dirs.filter((d) =>
135
+ !dirs.some((other) => other !== d && d.startsWith(other + "/"))
136
+ );
137
+
138
+ return deduped.length > 0 ? deduped : [projectDir];
139
+ }
140
+
141
+ /**
142
+ * Generates a stable ID for a pattern finding based on pattern ID, file, and line number.
143
+ * @param {string} patternId
144
+ * @param {string} file
145
+ * @param {number} line
146
+ * @returns {string}
147
+ */
148
+ export function makeFindingId(patternId, file, line) {
149
+ const key = `${patternId}||${file}||${line}`;
150
+ return `PAT-${createHash("sha256").update(key).digest("hex").slice(0, 6)}`;
151
+ }
152
+
153
+ /**
154
+ * Applies context validation for patterns that require manual verification.
155
+ * Returns true (confirmed violation) if the context does NOT contain a resolution.
156
+ * @param {Object} pattern
157
+ * @param {string[]} lines
158
+ * @param {number} lineIndex
159
+ * @returns {boolean}
160
+ */
161
+ export function isConfirmedByContext(pattern, lines, lineIndex) {
162
+ if (!pattern.requires_manual_verification || !pattern.context_reject_regex) return true;
163
+
164
+ const window = pattern.context_window || 5;
165
+ const start = Math.max(0, lineIndex - window);
166
+ const end = Math.min(lines.length - 1, lineIndex + window);
167
+ const nearby = lines.slice(start, end + 1).join("\n");
168
+
169
+ const rejectRe = new RegExp(pattern.context_reject_regex, "i");
170
+ return !rejectRe.test(nearby);
171
+ }
172
+
173
+ /**
174
+ * Scans all files matching a pattern's globs for regex matches.
175
+ * @param {Object} pattern
176
+ * @param {string} scanDir - Directory to walk (may be a sub-directory of projectDir).
177
+ * @param {string} [projectDir] - Root used for relative file paths (defaults to scanDir).
178
+ * @returns {Object[]}
179
+ */
180
+ export function scanPattern(pattern, scanDir, projectDir = scanDir) {
181
+ const findings = [];
182
+ const extensions = parseExtensions(pattern.globs);
183
+ const files = walkFiles(scanDir, extensions);
184
+ const regex = new RegExp(pattern.regex, "gi");
185
+
186
+ for (const file of files) {
187
+ let content;
188
+ try {
189
+ content = readFileSync(file, "utf-8");
190
+ } catch {
191
+ continue;
192
+ }
193
+
194
+ const lines = content.split("\n");
195
+
196
+ for (let i = 0; i < lines.length; i++) {
197
+ regex.lastIndex = 0;
198
+ if (!regex.test(lines[i])) continue;
199
+
200
+ const contextStart = Math.max(0, i - 3);
201
+ const contextEnd = Math.min(lines.length - 1, i + 3);
202
+ const context = lines
203
+ .slice(contextStart, contextEnd + 1)
204
+ .map((l, idx) => `${contextStart + idx + 1} ${l}`)
205
+ .join("\n");
206
+
207
+ const confirmed = isConfirmedByContext(pattern, lines, i);
208
+ const relFile = relative(projectDir, file);
209
+
210
+ findings.push({
211
+ id: makeFindingId(pattern.id, relFile, i + 1),
212
+ pattern_id: pattern.id,
213
+ title: pattern.title,
214
+ severity: pattern.severity,
215
+ wcag: pattern.wcag,
216
+ wcag_criterion: pattern.wcag_criterion,
217
+ wcag_level: pattern.wcag_level,
218
+ type: pattern.type,
219
+ fix_description: pattern.fix_description ?? null,
220
+ status: confirmed ? "confirmed" : "potential",
221
+ file: relFile,
222
+ line: i + 1,
223
+ match: lines[i].trim(),
224
+ context,
225
+ source: "code-pattern",
226
+ });
227
+ }
228
+ }
229
+
230
+ return findings;
231
+ }
232
+
233
+ /**
234
+ * Main execution function for the pattern scanner.
235
+ */
236
+ function main() {
237
+ const args = parseArgs(process.argv.slice(2));
238
+ const { patterns } = loadAssetJson(
239
+ ASSET_PATHS.remediation.codePatterns,
240
+ "assets/remediation/code-patterns.json",
241
+ );
242
+
243
+ const activePatterns = args.onlyPattern
244
+ ? patterns.filter((p) => p.id === args.onlyPattern)
245
+ : patterns;
246
+
247
+ if (activePatterns.length === 0) {
248
+ log.warn(args.onlyPattern
249
+ ? `Pattern "${args.onlyPattern}" not found in code-patterns.json`
250
+ : "No patterns defined in code-patterns.json"
251
+ );
252
+ process.exit(0);
253
+ }
254
+
255
+ const scanDirs = resolveScanDirs(args.framework, args.projectDir);
256
+ log.info(`Scanning source code at: ${args.projectDir}`);
257
+ if (scanDirs.length > 1 || scanDirs[0] !== args.projectDir) {
258
+ log.info(` Scoped to: ${scanDirs.map((d) => relative(args.projectDir, d)).join(", ")}`);
259
+ }
260
+ log.info(`Running ${activePatterns.length} pattern(s)...`);
261
+
262
+ const allFindings = [];
263
+ for (const pattern of activePatterns) {
264
+ const findings = [];
265
+ for (const scanDir of scanDirs) {
266
+ findings.push(...scanPattern(pattern, scanDir, args.projectDir));
267
+ }
268
+ if (findings.length > 0) {
269
+ log.info(` ${pattern.id}: ${findings.length} match(es)`);
270
+ }
271
+ allFindings.push(...findings);
272
+ }
273
+
274
+ const confirmed = allFindings.filter((f) => f.status === "confirmed").length;
275
+ const potential = allFindings.filter((f) => f.status === "potential").length;
276
+
277
+ writeJson(args.output, {
278
+ generated_at: new Date().toISOString(),
279
+ project_dir: args.projectDir,
280
+ findings: allFindings,
281
+ summary: {
282
+ total: allFindings.length,
283
+ confirmed,
284
+ potential,
285
+ },
286
+ });
287
+
288
+ log.success(
289
+ `Pattern scan complete. ${confirmed} confirmed, ${potential} potential. Saved to ${args.output}`,
290
+ );
291
+ }
292
+
293
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
294
+ try {
295
+ main();
296
+ } catch (error) {
297
+ log.error(error.message);
298
+ process.exit(1);
299
+ }
300
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * @file report-checklist.mjs
3
+ * @description Generates a standalone manual accessibility testing checklist.
4
+ * Does not depend on scan results — reads directly from assets/reporting/manual-checks.json.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { log, getInternalPath } from "../../core/utils.mjs";
10
+ import { ASSET_PATHS, loadAssetJson } from "../../core/asset-loader.mjs";
11
+ import { buildManualCheckCard } from "../renderers/html.mjs";
12
+ import { escapeHtml } from "../renderers/utils.mjs";
13
+
14
+ const MANUAL_CHECKS = loadAssetJson(
15
+ ASSET_PATHS.reporting.manualChecks,
16
+ "assets/reporting/manual-checks.json",
17
+ );
18
+
19
+ const TOTAL = MANUAL_CHECKS.length;
20
+ const COUNT_A = MANUAL_CHECKS.filter((c) => c.level === "A").length;
21
+ const COUNT_AA = MANUAL_CHECKS.filter((c) => c.level === "AA").length;
22
+ const COUNT_AT = MANUAL_CHECKS.filter((c) => c.level === "AT").length;
23
+
24
+ function parseArgs(argv) {
25
+ const args = { output: "", baseUrl: "" };
26
+ for (let i = 0; i < argv.length; i += 1) {
27
+ if (argv[i] === "--output") args.output = argv[i + 1] ?? "";
28
+ if (argv[i] === "--base-url") args.baseUrl = argv[i + 1] ?? "";
29
+ }
30
+ return args;
31
+ }
32
+
33
+ function buildHtml(args) {
34
+ const cards = MANUAL_CHECKS.map((c) => buildManualCheckCard(c)).join("\n");
35
+ const siteLabel = args.baseUrl || "your site";
36
+
37
+ const selectClasses =
38
+ "pl-4 pr-10 py-3 bg-white border border-slate-300 rounded-2xl text-sm font-bold text-slate-800 focus:outline-none focus:ring-4 focus:ring-amber-500/20 focus:border-amber-400 shadow-sm transition-all appearance-none cursor-pointer bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23374151%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%3E%3Cpath%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat";
39
+
40
+ return `<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <meta name="viewport" content="width=device-width, initial-scale=1">
45
+ <title>Manual Accessibility Checklist &mdash; ${escapeHtml(siteLabel)}</title>
46
+ <script src="https://cdn.tailwindcss.com"></script>
47
+ <link rel="preconnect" href="https://fonts.googleapis.com">
48
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
49
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
50
+ <style>
51
+ :root {
52
+ --amber: hsl(38, 92%, 50%);
53
+ --slate-50: #f8fafc; --slate-100: #f1f5f9; --slate-200: #e2e8f0;
54
+ --slate-300: #cbd5e1; --slate-400: #94a3b8; --slate-500: #64748b;
55
+ --slate-600: #475569; --slate-700: #334155; --slate-800: #1e293b;
56
+ --slate-900: #0f172a;
57
+ }
58
+ html { scroll-padding-top: 80px; }
59
+ body { background-color: var(--slate-50); font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; letter-spacing: -0.011em; }
60
+ .glass-header { background: rgba(255,255,255,0.85); backdrop-filter: blur(12px) saturate(180%); -webkit-backdrop-filter: blur(12px) saturate(180%); }
61
+ </style>
62
+ </head>
63
+ <body class="text-slate-900 min-h-screen">
64
+
65
+ <header class="fixed top-0 left-0 right-0 z-50 glass-header border-b border-slate-200/80 shadow-sm" id="navbar">
66
+ <nav aria-label="Checklist header">
67
+ <div class="max-w-4xl mx-auto px-4 h-16 flex justify-between items-center">
68
+ <div class="flex items-center gap-3">
69
+ <div class="px-3 h-10 rounded-lg bg-slate-900 text-white font-bold text-base font-mono flex items-center justify-center shadow-md">a11y</div>
70
+ <h1 class="text-xl font-bold">Manual <span class="text-slate-500">Checklist</span></h1>
71
+ </div>
72
+ <span class="text-sm text-slate-500 font-medium">${escapeHtml(siteLabel)}</span>
73
+ </div>
74
+ </nav>
75
+ </header>
76
+
77
+ <main id="main-content" class="max-w-4xl mx-auto px-4 pt-24 pb-20">
78
+
79
+ <!-- Why section -->
80
+ <div class="mb-12">
81
+ <h2 class="text-3xl font-extrabold mb-3">Why manual testing?</h2>
82
+ <p class="text-slate-500 text-base leading-relaxed mb-8 max-w-2xl">For people who rely on screen readers or keyboard navigation, an inaccessible website is a locked door. Automated scanners catch roughly 40% of WCAG violations. This checklist covers the rest.</p>
83
+
84
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
85
+ <div class="bg-white rounded-2xl border border-indigo-100 p-5 shadow-sm">
86
+ <div class="text-3xl font-black text-slate-900 mb-1">${COUNT_A}</div>
87
+ <div class="text-xs font-bold text-indigo-600 uppercase tracking-widest mb-2">WCAG A</div>
88
+ <p class="text-xs text-slate-500 leading-relaxed">Baseline conformance. Verifiable with a browser and keyboard only.</p>
89
+ </div>
90
+ <div class="bg-white rounded-2xl border border-amber-100 p-5 shadow-sm">
91
+ <div class="text-3xl font-black text-slate-900 mb-1">${COUNT_AA}</div>
92
+ <div class="text-xs font-bold text-amber-700 uppercase tracking-widest mb-2">WCAG AA</div>
93
+ <p class="text-xs text-slate-500 leading-relaxed">Legal compliance target in most jurisdictions. Requires DevTools for some checks.</p>
94
+ </div>
95
+ <div class="bg-white rounded-2xl border border-violet-100 p-5 shadow-sm">
96
+ <div class="text-3xl font-black text-slate-900 mb-1">${COUNT_AT}</div>
97
+ <div class="text-xs font-bold text-violet-600 uppercase tracking-widest mb-2">Assistive Technology</div>
98
+ <p class="text-xs text-slate-500 leading-relaxed">Requires VoiceOver (macOS) or NVDA (Windows) to verify real screen reader behavior.</p>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="bg-slate-900 rounded-2xl p-6 text-sm text-slate-300 leading-relaxed">
103
+ <p class="font-bold text-white mb-4">What you need for this session</p>
104
+ <ul class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-2.5 list-none">
105
+ <li class="flex items-start gap-2"><span class="text-amber-400 font-bold mt-0.5">→</span> A browser with DevTools open</li>
106
+ <li class="flex items-start gap-2"><span class="text-amber-400 font-bold mt-0.5">→</span> <strong class="text-slate-100">${escapeHtml(siteLabel)}</strong> open in another tab</li>
107
+ <li class="flex items-start gap-2"><span class="text-amber-400 font-bold mt-0.5">→</span> Keyboard only for navigation checks</li>
108
+ <li class="flex items-start gap-2"><span class="text-amber-400 font-bold mt-0.5">→</span> VoiceOver / NVDA for <span class="text-violet-400 font-semibold">AT checks</span></li>
109
+ </ul>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- Toolbar -->
114
+ <div id="checklist-toolbar" class="sticky top-16 z-40 bg-slate-50/95 backdrop-blur-md py-4 border-b border-slate-200/80 mb-8 space-y-4">
115
+
116
+ <div id="manual-progress-sticky" class="bg-white rounded-xl border border-slate-200 p-4 flex items-center gap-4 shadow-sm">
117
+ <div class="flex-1">
118
+ <div class="flex justify-between text-xs font-medium text-slate-500 mb-1.5">
119
+ <span>Verification progress</span>
120
+ <span id="manual-progress-label">0 / ${TOTAL} verified</span>
121
+ </div>
122
+ <div class="w-full bg-slate-100 rounded-full h-2">
123
+ <div id="manual-progress-bar" class="bg-emerald-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <div class="flex items-center gap-3">
129
+ <div class="relative flex-1">
130
+ <input type="text" id="search-input" oninput="filterChecks()" placeholder="Search checks…" class="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm font-medium focus:outline-none focus:ring-4 focus:ring-amber-500/20 focus:border-amber-400 transition-all shadow-sm">
131
+ <svg class="absolute left-4 top-3.5 w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
132
+ </div>
133
+ <label for="level-select" class="sr-only">Filter checklist by level</label>
134
+ <select id="level-select" onchange="filterChecks()" class="${selectClasses}">
135
+ <option value="all">All levels (${TOTAL})</option>
136
+ <option value="A">WCAG A (${COUNT_A})</option>
137
+ <option value="AA">WCAG AA (${COUNT_AA})</option>
138
+ <option value="AT">Assistive Tech (${COUNT_AT})</option>
139
+ </select>
140
+ <button onclick="toggleAllCards()" id="expand-all-btn" class="px-5 py-3 rounded-2xl border border-slate-200 bg-white text-xs font-bold uppercase tracking-widest text-slate-600 hover:border-slate-300 hover:text-slate-800 shadow-sm transition-all whitespace-nowrap">Expand all</button>
141
+ </div>
142
+
143
+ </div>
144
+
145
+ <!-- Cards -->
146
+ <div id="checklist-container" class="space-y-4">
147
+ ${cards}
148
+ </div>
149
+
150
+ <div id="empty-state" class="hidden text-center py-16 text-slate-400">
151
+ <p class="text-sm font-bold">No checks match your filter.</p>
152
+ </div>
153
+
154
+ <footer class="mt-12 py-6 border-t border-slate-200 text-center">
155
+ <p class="text-slate-600 text-sm font-medium">Generated by <a href="https://github.com/diegovelasquezweb/a11y" target="_blank" class="text-slate-700 hover:text-amber-700 font-semibold transition-colors">a11y</a> &bull; <span class="text-slate-700">WCAG 2.2 AA</span></p>
156
+ </footer>
157
+
158
+ </main>
159
+
160
+ <script>
161
+ const TOTAL_CHECKS = ${TOTAL};
162
+
163
+ function updateProgress() {
164
+ let verified = 0, na = 0;
165
+ document.querySelectorAll('.manual-card').forEach(c => {
166
+ if (c.style.display === 'none') return;
167
+ const s = c.dataset.state;
168
+ if (s === 'verified') verified++;
169
+ else if (s === 'na') na++;
170
+ });
171
+ const visible = [...document.querySelectorAll('.manual-card')].filter(c => c.style.display !== 'none').length;
172
+ const applicable = visible - na;
173
+ const pct = applicable > 0 ? Math.round((verified / applicable) * 100) : (na === visible && visible > 0 ? 100 : 0);
174
+ let label = verified + ' / ' + applicable + ' verified';
175
+ if (na > 0) label += ' · ' + na + ' N/A';
176
+ document.getElementById('manual-progress-label').textContent = label;
177
+ document.getElementById('manual-progress-bar').style.width = pct + '%';
178
+ }
179
+
180
+ function applyCardState(card, state) {
181
+ const header = card.querySelector('.manual-header');
182
+ const badge = card.querySelector('.manual-badge');
183
+ const verifiedBtn = card.querySelector('.manual-verified-btn');
184
+ const naBtn = card.querySelector('.manual-na-btn');
185
+ card.dataset.state = state || '';
186
+ const BASE_VERIFIED = 'manual-verified-btn px-3 py-1.5 rounded-full text-xs font-bold border transition-colors whitespace-nowrap';
187
+ const BASE_NA = 'manual-na-btn px-3 py-1.5 rounded-full text-xs font-bold border transition-colors whitespace-nowrap';
188
+ card.classList.remove('border-amber-200', 'border-emerald-200', 'border-slate-300');
189
+ header.classList.remove('from-amber-50\\/60', 'hover:from-amber-50', 'from-emerald-50\\/60', 'hover:from-emerald-50', 'from-slate-50\\/60', 'hover:from-slate-50');
190
+ if (state === 'verified') {
191
+ card.classList.add('border-emerald-200');
192
+ header.classList.add('from-emerald-50\\/60', 'hover:from-emerald-50');
193
+ badge.textContent = '✓ Verified';
194
+ badge.className = 'manual-badge px-2.5 py-0.5 rounded-full text-xs font-bold border bg-emerald-100 text-emerald-700 border-emerald-200';
195
+ if (verifiedBtn) verifiedBtn.className = BASE_VERIFIED + ' border-emerald-400 bg-emerald-100 text-emerald-700';
196
+ if (naBtn) naBtn.className = BASE_NA + ' border-slate-200 bg-white text-slate-500 hover:border-slate-400 hover:text-slate-600 hover:bg-slate-50';
197
+ } else if (state === 'na') {
198
+ card.classList.add('border-slate-300');
199
+ header.classList.add('from-slate-50\\/60', 'hover:from-slate-50');
200
+ badge.textContent = 'N/A';
201
+ badge.className = 'manual-badge px-2.5 py-0.5 rounded-full text-xs font-bold border bg-slate-100 text-slate-600 border-slate-300';
202
+ if (verifiedBtn) verifiedBtn.className = BASE_VERIFIED + ' border-slate-200 bg-white text-slate-500 hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50';
203
+ if (naBtn) naBtn.className = BASE_NA + ' border-slate-400 bg-slate-100 text-slate-600';
204
+ } else {
205
+ card.classList.add('border-amber-200');
206
+ header.classList.add('from-amber-50\\/60', 'hover:from-amber-50');
207
+ badge.textContent = 'Manual';
208
+ badge.className = 'manual-badge px-2.5 py-0.5 rounded-full text-xs font-bold border bg-amber-100 text-amber-800 border-amber-200';
209
+ if (verifiedBtn) verifiedBtn.className = BASE_VERIFIED + ' border-slate-200 bg-white text-slate-500 hover:border-emerald-300 hover:text-emerald-700 hover:bg-emerald-50';
210
+ if (naBtn) naBtn.className = BASE_NA + ' border-slate-200 bg-white text-slate-500 hover:border-slate-400 hover:text-slate-600 hover:bg-slate-50';
211
+ }
212
+ }
213
+
214
+ function setManualState(criterion, newState) {
215
+ const card = document.getElementById('manual-' + criterion.replace(/\\./g, '-'));
216
+ const current = card ? card.dataset.state : '';
217
+ const next = current === newState ? '' : newState;
218
+ if (card) applyCardState(card, next);
219
+ updateProgress();
220
+ }
221
+
222
+ function toggleCard(header) {
223
+ const card = header.closest('.manual-card');
224
+ const body = card.querySelector('.card-body');
225
+ const chevron = header.querySelector('.card-chevron');
226
+ const isCollapsed = card.dataset.collapsed === 'true';
227
+ body.style.gridTemplateRows = isCollapsed ? '1fr' : '0fr';
228
+ card.dataset.collapsed = isCollapsed ? 'false' : 'true';
229
+ if (chevron) chevron.style.transform = isCollapsed ? 'rotate(180deg)' : '';
230
+ header.setAttribute('aria-expanded', isCollapsed ? 'true' : 'false');
231
+ _syncExpandBtn();
232
+ }
233
+
234
+ function _visibleCards() {
235
+ return [...document.querySelectorAll('.manual-card')].filter(c => c.style.display !== 'none');
236
+ }
237
+
238
+ function _syncExpandBtn() {
239
+ const btn = document.getElementById('expand-all-btn');
240
+ if (!btn) return;
241
+ const anyCollapsed = _visibleCards().some(c => c.dataset.collapsed === 'true');
242
+ btn.textContent = anyCollapsed ? 'Expand all' : 'Collapse all';
243
+ }
244
+
245
+ function toggleAllCards() {
246
+ const cards = _visibleCards();
247
+ const anyCollapsed = cards.some(c => c.dataset.collapsed === 'true');
248
+ cards.forEach(card => {
249
+ const body = card.querySelector('.card-body');
250
+ const chevron = card.querySelector('.card-chevron');
251
+ const header = card.querySelector('.card-header');
252
+ body.style.gridTemplateRows = anyCollapsed ? '1fr' : '0fr';
253
+ card.dataset.collapsed = anyCollapsed ? 'false' : 'true';
254
+ if (chevron) chevron.style.transform = anyCollapsed ? 'rotate(180deg)' : '';
255
+ if (header) header.setAttribute('aria-expanded', anyCollapsed ? 'true' : 'false');
256
+ });
257
+ const btn = document.getElementById('expand-all-btn');
258
+ if (btn) btn.textContent = anyCollapsed ? 'Collapse all' : 'Expand all';
259
+ }
260
+
261
+ function filterChecks() {
262
+ const q = document.getElementById('search-input').value.toLowerCase().trim();
263
+ const level = document.getElementById('level-select').value;
264
+ let visible = 0;
265
+ document.querySelectorAll('.manual-card').forEach(card => {
266
+ const cardLevel = card.dataset.level || '';
267
+ const title = card.querySelector('h3')?.textContent.toLowerCase() || '';
268
+ const criterion = card.dataset.criterion?.toLowerCase() || '';
269
+ const levelMatch = level === 'all' || cardLevel === level;
270
+ const textMatch = !q || title.includes(q) || criterion.includes(q);
271
+ if (levelMatch && textMatch) {
272
+ card.style.display = '';
273
+ visible++;
274
+ } else {
275
+ card.style.display = 'none';
276
+ }
277
+ });
278
+ document.getElementById('empty-state').classList.toggle('hidden', visible > 0);
279
+ _syncExpandBtn();
280
+ updateProgress();
281
+ }
282
+
283
+ window.addEventListener('scroll', () => {
284
+ document.getElementById('navbar').classList.toggle('shadow-md', window.scrollY > 20);
285
+ });
286
+ </script>
287
+
288
+ </body>
289
+ </html>`;
290
+ }
291
+
292
+ function main() {
293
+ const args = parseArgs(process.argv.slice(2));
294
+ if (!args.output) throw new Error("Missing required --output flag.");
295
+
296
+ const html = buildHtml(args);
297
+ fs.mkdirSync(path.dirname(args.output), { recursive: true });
298
+ fs.writeFileSync(args.output, html, "utf-8");
299
+ log.success(`Manual checklist written to ${args.output}`);
300
+ }
301
+
302
+ try {
303
+ main();
304
+ } catch (error) {
305
+ log.error(`Checklist Error: ${error.message}`);
306
+ process.exit(1);
307
+ }