@hypoth-ui/a11y-audit 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 hypoth-org
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # @hypoth-ui/a11y-audit
2
+
3
+ ![Alpha](https://img.shields.io/badge/status-alpha-orange)
4
+
5
+ Accessibility audit and conformance reporting tools for the hypoth-ui design system. Provides automated CI checks, manual audit checklists, and WCAG conformance report generation.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @hypoth-ui/a11y-audit
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### Run Automated Accessibility Tests
16
+
17
+ ```bash
18
+ pnpm test:a11y
19
+ ```
20
+
21
+ ### Start a Manual Audit
22
+
23
+ ```bash
24
+ pnpm a11y:audit -- --component ds-button --category form-controls
25
+ ```
26
+
27
+ Available categories: `form-controls`, `overlays`, `navigation`, `data-display`, `feedback`.
28
+
29
+ ### Generate a Conformance Report
30
+
31
+ ```bash
32
+ pnpm a11y:report -- --version 1.0.0
33
+ ```
34
+
35
+ ### Validate Audit Records
36
+
37
+ ```bash
38
+ pnpm a11y:validate
39
+ ```
40
+
41
+ ## Documentation
42
+
43
+ See the [main README](../../README.md) for full documentation and architecture overview.
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,266 @@
1
+ import {
2
+ validateRecord
3
+ } from "./chunk-KLSGW6PX.js";
4
+
5
+ // src/cli/audit.ts
6
+ import * as os from "os";
7
+ import * as path3 from "path";
8
+
9
+ // src/lib/artifact.ts
10
+ import { randomUUID } from "crypto";
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ function calculateOverallStatus(items) {
14
+ const hasBlocked = items.some((i) => i.status === "blocked");
15
+ const hasFail = items.some((i) => i.status === "fail");
16
+ const allPass = items.every((i) => i.status === "pass" || i.status === "na");
17
+ if (hasBlocked) {
18
+ return "partial";
19
+ }
20
+ if (hasFail) {
21
+ const failCount = items.filter((i) => i.status === "fail").length;
22
+ const passCount = items.filter((i) => i.status === "pass").length;
23
+ if (failCount > passCount) {
24
+ return "non-conformant";
25
+ }
26
+ return "partial";
27
+ }
28
+ if (allPass) {
29
+ return "conformant";
30
+ }
31
+ return "partial";
32
+ }
33
+ function validateCompleteness(checklist, items) {
34
+ const completedIds = new Set(items.map((i) => i.itemId));
35
+ const missing = checklist.items.filter((item) => !completedIds.has(item.id)).map((item) => item.id);
36
+ return {
37
+ complete: missing.length === 0,
38
+ missing
39
+ };
40
+ }
41
+ function generateArtifact(options) {
42
+ const completeness = validateCompleteness(options.checklist, options.items);
43
+ if (!completeness.complete) {
44
+ throw new Error(`Incomplete audit: missing items ${completeness.missing.join(", ")}`);
45
+ }
46
+ const record = {
47
+ id: randomUUID(),
48
+ component: options.component,
49
+ version: options.version,
50
+ checklistId: options.checklist.id,
51
+ checklistVersion: options.checklist.version,
52
+ auditor: options.auditor,
53
+ auditDate: (/* @__PURE__ */ new Date()).toISOString(),
54
+ items: options.items,
55
+ overallStatus: calculateOverallStatus(options.items),
56
+ notes: options.notes
57
+ };
58
+ const validation = validateRecord(record);
59
+ if (!validation.valid) {
60
+ throw new Error(`Invalid audit record: ${validation.errors.join(", ")}`);
61
+ }
62
+ return record;
63
+ }
64
+ function saveArtifact(record, outputDir) {
65
+ const componentDir = path.join(outputDir, record.component);
66
+ if (!fs.existsSync(componentDir)) {
67
+ fs.mkdirSync(componentDir, { recursive: true });
68
+ }
69
+ const filename = `${record.version}.json`;
70
+ const filepath = path.join(componentDir, filename);
71
+ if (fs.existsSync(filepath)) {
72
+ const backupPath = filepath.replace(".json", `.backup-${Date.now()}.json`);
73
+ fs.renameSync(filepath, backupPath);
74
+ console.info(`Existing record backed up to: ${backupPath}`);
75
+ }
76
+ fs.writeFileSync(filepath, JSON.stringify(record, null, 2));
77
+ return filepath;
78
+ }
79
+
80
+ // src/lib/checklist-runner.ts
81
+ import * as fs2 from "fs";
82
+ import * as path2 from "path";
83
+ import * as readline from "readline";
84
+ function loadChecklist(category, templatesDir) {
85
+ const templatePath = path2.join(templatesDir, `${category}.json`);
86
+ if (!fs2.existsSync(templatePath)) {
87
+ throw new Error(`Checklist template not found: ${category}`);
88
+ }
89
+ const content = fs2.readFileSync(templatePath, "utf-8");
90
+ return JSON.parse(content);
91
+ }
92
+ function getAvailableCategories(templatesDir) {
93
+ if (!fs2.existsSync(templatesDir)) {
94
+ return [];
95
+ }
96
+ return fs2.readdirSync(templatesDir).filter((f) => f.endsWith(".json")).map((f) => f.replace(".json", ""));
97
+ }
98
+ function createSession(options) {
99
+ const checklist = loadChecklist(options.category, options.templatesDir);
100
+ return {
101
+ checklist,
102
+ component: options.component,
103
+ version: options.version ?? "0.0.0",
104
+ items: [],
105
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
106
+ };
107
+ }
108
+ function parseStatus(input) {
109
+ const normalized = input.toLowerCase().trim();
110
+ switch (normalized) {
111
+ case "p":
112
+ case "pass":
113
+ return "pass";
114
+ case "f":
115
+ case "fail":
116
+ return "fail";
117
+ case "n":
118
+ case "na":
119
+ case "n/a":
120
+ return "na";
121
+ case "b":
122
+ case "blocked":
123
+ return "blocked";
124
+ case "s":
125
+ case "skip":
126
+ return "skip";
127
+ default:
128
+ return null;
129
+ }
130
+ }
131
+ async function runInteractiveSession(session) {
132
+ const rl = readline.createInterface({
133
+ input: process.stdin,
134
+ output: process.stdout
135
+ });
136
+ const question = (prompt) => new Promise((resolve2) => rl.question(prompt, resolve2));
137
+ const results = [];
138
+ for (const item of session.checklist.items) {
139
+ let status = null;
140
+ while (!status) {
141
+ const input = await question("Enter status: ");
142
+ const parsed = parseStatus(input);
143
+ if (parsed === "skip") {
144
+ break;
145
+ }
146
+ if (parsed === null) {
147
+ continue;
148
+ }
149
+ status = parsed;
150
+ }
151
+ if (status) {
152
+ let notes = "";
153
+ if (status === "fail" || status === "blocked") {
154
+ notes = await question("Enter notes (required for fail/blocked): ");
155
+ while (!notes.trim()) {
156
+ notes = await question("Notes are required. Please enter notes: ");
157
+ }
158
+ } else {
159
+ const notesInput = await question("Enter notes (optional, press Enter to skip): ");
160
+ notes = notesInput.trim();
161
+ }
162
+ results.push({
163
+ itemId: item.id,
164
+ status,
165
+ notes: notes || void 0
166
+ });
167
+ }
168
+ }
169
+ rl.close();
170
+ return results;
171
+ }
172
+
173
+ // src/cli/audit.ts
174
+ var VALID_CATEGORIES = ["form-controls", "overlays", "navigation", "data-display", "feedback"];
175
+ async function audit(options) {
176
+ const { component, category, version = "0.0.0" } = options;
177
+ if (!component.match(/^ds-[a-z][a-z0-9-]*$/)) {
178
+ console.error(`\u274C Invalid component ID: ${component}`);
179
+ console.error(" Component ID must match pattern: ds-<name> (e.g., ds-button)");
180
+ process.exit(1);
181
+ }
182
+ if (!VALID_CATEGORIES.includes(category)) {
183
+ console.error(`\u274C Invalid category: ${category}`);
184
+ console.error(` Valid categories: ${VALID_CATEGORIES.join(", ")}`);
185
+ process.exit(1);
186
+ }
187
+ const templatesDir = path3.resolve(process.cwd(), "packages/a11y-audit/src/templates");
188
+ const outputDir = path3.resolve(process.cwd(), "a11y-audits/records");
189
+ const categories = getAvailableCategories(templatesDir);
190
+ if (categories.length === 0) {
191
+ console.error(`\u274C No checklist templates found in: ${templatesDir}`);
192
+ process.exit(1);
193
+ }
194
+ if (!categories.includes(category)) {
195
+ console.error(`\u274C Checklist template not found: ${category}`);
196
+ console.error(` Available: ${categories.join(", ")}`);
197
+ process.exit(1);
198
+ }
199
+ console.info(`
200
+ \u{1F50D} Starting accessibility audit
201
+ Component: ${component}
202
+ Category: ${category}
203
+ Version: ${version}
204
+ `);
205
+ try {
206
+ const session = createSession({
207
+ component,
208
+ category,
209
+ version,
210
+ templatesDir
211
+ });
212
+ const items = await runInteractiveSession(session);
213
+ if (items.length === 0) {
214
+ console.info("\n\u26A0\uFE0F No items completed. Audit cancelled.");
215
+ process.exit(0);
216
+ }
217
+ if (items.length < session.checklist.items.length) {
218
+ console.info(
219
+ `
220
+ \u26A0\uFE0F Audit incomplete: ${items.length}/${session.checklist.items.length} items completed`
221
+ );
222
+ console.info(" Run the audit again to complete remaining items.");
223
+ process.exit(1);
224
+ }
225
+ const auditor = process.env.USER || os.userInfo().username || "unknown@example.com";
226
+ const record = generateArtifact({
227
+ component,
228
+ version,
229
+ checklist: session.checklist,
230
+ items,
231
+ auditor: `${auditor}@example.com`,
232
+ outputDir
233
+ });
234
+ const filepath = saveArtifact(record, outputDir);
235
+ const passCount = items.filter((i) => i.status === "pass").length;
236
+ const failCount = items.filter((i) => i.status === "fail").length;
237
+ const naCount = items.filter((i) => i.status === "na").length;
238
+ const blockedCount = items.filter((i) => i.status === "blocked").length;
239
+ console.info(`
240
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
241
+ \u2551 AUDIT COMPLETE \u2551
242
+ \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
243
+ \u2551 Overall Status: ${record.overallStatus.toUpperCase().padEnd(44)}\u2551
244
+ \u2551 \u2551
245
+ \u2551 Results: \u2551
246
+ \u2551 \u2705 Pass: ${String(passCount).padEnd(48)}\u2551
247
+ \u2551 \u274C Fail: ${String(failCount).padEnd(48)}\u2551
248
+ \u2551 \u2796 N/A: ${String(naCount).padEnd(48)}\u2551
249
+ \u2551 \u{1F6AB} Blocked: ${String(blockedCount).padEnd(48)}\u2551
250
+ \u2551 \u2551
251
+ \u2551 Artifact saved to: \u2551
252
+ \u2551 ${filepath.slice(0, 59).padEnd(60)}\u2551
253
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
254
+ `);
255
+ console.info("\u{1F4DD} Remember to commit this audit record to version control:");
256
+ console.info(` git add ${filepath}`);
257
+ console.info(` git commit -m "Add a11y audit for ${component} v${version}"`);
258
+ } catch (error) {
259
+ console.error(`
260
+ \u274C Audit failed: ${error instanceof Error ? error.message : String(error)}`);
261
+ process.exit(1);
262
+ }
263
+ }
264
+ export {
265
+ audit
266
+ };
@@ -0,0 +1,39 @@
1
+ // src/lib/config.ts
2
+ var DEFAULT_SEVERITY_THRESHOLD = {
3
+ failOn: ["critical", "serious"]
4
+ };
5
+ var SEVERITY_LEVELS = ["critical", "serious", "moderate", "minor"];
6
+ var SEVERITY_ENV_VAR = "A11Y_SEVERITY";
7
+ function parseSeverityThreshold(value) {
8
+ const input = value ?? process.env[SEVERITY_ENV_VAR];
9
+ if (!input) {
10
+ return DEFAULT_SEVERITY_THRESHOLD;
11
+ }
12
+ if (input.toLowerCase() === "all") {
13
+ return { failOn: [...SEVERITY_LEVELS] };
14
+ }
15
+ const levels = input.toLowerCase().split(",").map((s) => s.trim()).filter((s) => SEVERITY_LEVELS.includes(s));
16
+ if (levels.length === 0) {
17
+ console.warn(`Invalid severity threshold: "${input}". Using default.`);
18
+ return DEFAULT_SEVERITY_THRESHOLD;
19
+ }
20
+ return { failOn: levels };
21
+ }
22
+ function shouldFailOnSeverity(severity, threshold = DEFAULT_SEVERITY_THRESHOLD) {
23
+ return threshold.failOn.includes(severity);
24
+ }
25
+ function describeSeverityThreshold(threshold) {
26
+ if (threshold.failOn.length === SEVERITY_LEVELS.length) {
27
+ return "all violations";
28
+ }
29
+ return `${threshold.failOn.join(" or ")} violations`;
30
+ }
31
+
32
+ export {
33
+ DEFAULT_SEVERITY_THRESHOLD,
34
+ SEVERITY_LEVELS,
35
+ SEVERITY_ENV_VAR,
36
+ parseSeverityThreshold,
37
+ shouldFailOnSeverity,
38
+ describeSeverityThreshold
39
+ };