@appfire-ux/audit-agent 0.20260702.2 → 0.20260706.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @appfire-ux/audit-agent
2
2
 
3
- One-command install of two Claude Code audit agents for Appfire repos. **Every audit run refreshes both npm packages to `@latest`** (Phase 0):
3
+ One-command install of three Claude Code audit agents for Appfire repos. **Every audit run refreshes both npm packages to `@latest`** (Phase 0):
4
4
 
5
5
  - **`@appfire-ux/audit-agent`** — agent/command definitions in `.claude/`
6
6
  - **`@appfire-ux/guidelines`** — rules, skills, and `guidelines/` kit
@@ -9,6 +9,7 @@ Agents:
9
9
 
10
10
  - **ux-auditor** (`/ux-audit`) — guideline-compliance UX audit: design tokens, spacing/typography, accessibility, UX writing, messaging patterns, persona fit.
11
11
  - **ui-auditor** (`/ui-audit`) — UI & usability audit from a UI Designer's perspective: cross-screen consistency, layout & visual hierarchy, information architecture, navigation, interaction design, forms, data-dense views, responsiveness. Screenshot-driven (browser MCP or bundled Playwright fallback).
12
+ - **a11y-auditor** (`/a11y-audit`) — deep accessibility audit per the Appfire VPAT Compliance Protocol: WCAG 2.2 A/AA, automated axe-core scans of the running app (Baseline Accessibility Score), static four-principles code audit, manual validation register, evidence-gap register, and JSON datapoints that feed the "AI Agent: VPAT Document" (Confluence, CRUX) to draft the Accessibility Conformance Report. Strict no-over-claiming language.
12
13
 
13
14
  ## Install
14
15
 
@@ -21,9 +22,10 @@ npm install -D @appfire-ux/audit-agent
21
22
  The postinstall script copies the agent and command into `<repo>/.claude/`. Done — open Claude Code and run:
22
23
 
23
24
  ```
24
- /full-audit # both audits in parallel (recommended)
25
+ /full-audit # all audits in parallel (recommended)
25
26
  /ux-audit # compliance only
26
27
  /ui-audit # UI & usability only
28
+ /a11y-audit # accessibility / VPAT readiness only
27
29
  ```
28
30
 
29
31
  Once, for all repos on your machine:
@@ -34,7 +36,7 @@ npx @appfire-ux/audit-agent --global # installs into ~/.claude/
34
36
 
35
37
  ## Update (manual, optional)
36
38
 
37
- Phase 0 on every `/ux-audit`, `/ui-audit`, or `/full-audit` run already calls `npm update` + `@latest` for both packages. Use this only when you want to refresh **without** running an audit:
39
+ Phase 0 on every `/ux-audit`, `/ui-audit`, `/a11y-audit`, or `/full-audit` run already calls `npm update` + `@latest` for both packages. Use this only when you want to refresh **without** running an audit:
38
40
 
39
41
  ```bash
40
42
  npm update @appfire-ux/audit-agent @appfire-ux/guidelines
@@ -46,17 +48,22 @@ npx @appfire-ux/guidelines
46
48
 
47
49
  | File | Purpose |
48
50
  |---|---|
49
- | `.claude/agents/ux-auditor.md` | 8-phase compliance audit: guidelines bootstrap static + runtime audit evidence-based report → self-verification |
50
- | `.claude/commands/ux-audit.md` | `/ux-audit [scope] [--static-only] [--lang pl] [--out <path>]` |
51
- | `.claude/agents/ui-auditor.md` | 8-phase UI & usability audit: guidelines bootstrap screen/pattern inventory screenshot capture → consistency/IA/interaction passes → report → self-verification |
52
- | `.claude/commands/ui-audit.md` | `/ui-audit [scope] [--static-only] [--lang pl] [--out <path>]` |
53
- | `.claude/commands/full-audit.md` | `/full-audit [scope] [--static-only] [--lang pl] [--out-dir <path>]` runs **ux-auditor** and **ui-auditor** in parallel with shared guidelines bootstrap and one dev server |
54
-
55
- The audits complement each other: `ux-audit` checks *compliance with the guidelines*, `ui-audit` checks *the interface as designed*. Use `/full-audit` when you want both at once; findings each agent notices outside its lane are handed off to the other's report section, and the combined summary deduplicates cross-cutting issues.
51
+ | `.claude/agents/ux-auditor.md` | Guideline-compliance audit → MD + HTML + `assets/*.png` (Confluence-ready) |
52
+ | `.claude/commands/ux-audit.md` | `/ux-audit [scope] [--static-only] [--lang pl] [--out <path>] [--prod]` |
53
+ | `.claude/agents/ui-auditor.md` | UI & usability audit → same deliverable format |
54
+ | `.claude/commands/ui-audit.md` | `/ui-audit [scope] [--static-only] [--lang pl] [--out <path>] [--prod]` |
55
+ | `.claude/agents/a11y-auditor.md` | Accessibility/VPAT audit MD + HTML + JSON datapoints + `axe-results/` |
56
+ | `.claude/commands/a11y-audit.md` | `/a11y-audit [scope] [--static-only] [--lang pl] [--out <path>] [--prod] [--routes …]` |
57
+ | `.claude/commands/full-audit.md` | `/full-audit [--agents ux,ui,a11y]` parallel run of selected agents, combined summary MD+HTML |
58
+ | `scripts/ux-audit-capture.mjs` | Playwright screenshot fallback (`--widths 1440,768`) |
59
+ | `scripts/a11y-audit-scan.mjs` | Playwright + axe-core scanner → per-route JSON + Baseline Score |
60
+ | `scripts/build-html-reports.mjs` | MD → Confluence-ready HTML with local `assets/` gallery |
61
+
62
+ The audits complement each other. Every run produces **Markdown + HTML + PNG assets** under `docs/audits/` (STOP & ASK on auth walls; no base64). Use `/full-audit` for all agents in parallel. The a11y audit additionally produces machine-readable datapoints (`A11Y-AUDIT-<date>.json` + `axe-results/*.json`) for the VPAT/ACR drafting workflow.
56
63
 
57
64
  ## Publishing (maintainers)
58
65
 
59
- **CI (preferred):** pushes to `main` that touch `appfire-ux-audit-agent/**` or the canonical agent/command files in `.claude/` (`ux-auditor.md`, `ux-audit.md`, `ui-auditor.md`, `ui-audit.md`, `full-audit.md`) run [`.github/workflows/publish-audit-agent.yml`](../.github/workflows/publish-audit-agent.yml). The workflow assembles the package (syncing templates from `.claude/`), bumps a date-based version (`0.YYYYMMDD.PATCH`), and publishes `@appfire-ux/audit-agent` using the `NPM_TOKEN` secret — same pattern as `@appfire-ux/guidelines` and `@appfire-ux/fonts`.
66
+ **CI (preferred):** pushes to `main` that touch `appfire-ux-audit-agent/**` or the canonical agent/command files in `.claude/` (`ux-auditor.md`, `ux-audit.md`, `ui-auditor.md`, `ui-audit.md`, `a11y-auditor.md`, `a11y-audit.md`, `full-audit.md`) run [`.github/workflows/publish-audit-agent.yml`](../.github/workflows/publish-audit-agent.yml). The workflow assembles the package (syncing templates from `.claude/`), bumps a date-based version (`0.YYYYMMDD.PATCH`), and publishes `@appfire-ux/audit-agent` using the `NPM_TOKEN` secret — same pattern as `@appfire-ux/guidelines` and `@appfire-ux/fonts`.
60
67
 
61
68
  **Manual:**
62
69
 
package/bin/install.js CHANGED
@@ -69,10 +69,11 @@ console.log('[appfire-ux-audit-agent] Installed ' + copied.length + ' file(s) in
69
69
  for (const f of copied) console.log(' - ' + f);
70
70
  console.log('');
71
71
  console.log('Usage (in Claude Code, inside your repo):');
72
- console.log(' /full-audit both audits in parallel (recommended for full coverage)');
72
+ console.log(' /full-audit all audits in parallel (recommended; --agents ux,ui,a11y)');
73
73
  console.log(' /ux-audit guideline-compliance UX audit (tokens, a11y, content, messaging)');
74
74
  console.log(' /ui-audit UI & usability audit (consistency, IA, interaction design)');
75
+ console.log(' /a11y-audit accessibility audit: WCAG 2.2 + axe-core scan, feeds VPAT/ACR');
75
76
  console.log(' All accept: [scope-path] [--static-only] [--lang pl]');
76
- console.log(' /full-audit also: [--out-dir <path>] /ux-audit|/ui-audit: [--out <path>]');
77
+ console.log(' /full-audit also: [--out-dir <path>] others: [--out <path>]');
77
78
  console.log('');
78
79
  console.log('Tip: run "npx appfire-ux-audit-agent --global" to install into ~/.claude for all repos.');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@appfire-ux/audit-agent",
3
- "version": "0.20260702.2",
4
- "description": "Claude Code audit agents for Appfire apps — installs ux-auditor (/ux-audit: guideline compliance) and ui-auditor (/ui-audit: UI consistency, IA, interaction design), auditing against @appfire-ux/guidelines",
3
+ "version": "0.20260706.0",
4
+ "description": "Claude Code audit agents for Appfire apps — ux-auditor (/ux-audit), ui-auditor (/ui-audit), a11y-auditor (/a11y-audit: WCAG 2.2 + axe-core scan feeding the Appfire VPAT/ACR process), /full-audit parallel orchestration. Confluence-ready MD+HTML+PNG output, runtime capture & scan scripts.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public",
@@ -12,6 +12,10 @@
12
12
  "ux-audit",
13
13
  "ui-audit",
14
14
  "usability",
15
+ "accessibility",
16
+ "a11y",
17
+ "wcag",
18
+ "vpat",
15
19
  "claude-code",
16
20
  "atlassian",
17
21
  "design-system"
@@ -28,6 +32,7 @@
28
32
  "scripts/"
29
33
  ],
30
34
  "optionalDependencies": {
31
- "playwright": "^1.47.0"
35
+ "playwright": "^1.47.0",
36
+ "axe-core": "^4.10.0"
32
37
  }
33
38
  }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Automated axe-core accessibility scanner for the a11y-auditor agent (Phase 3).
4
+ * Playwright loads each route, injects axe-core, and writes JSON results per route
5
+ * plus a summary.json with per-screen counts and the Appfire Baseline Score.
6
+ *
7
+ * Usage:
8
+ * npx playwright install chromium # once
9
+ * node scripts/a11y-audit-scan.mjs --url http://localhost:5173 \
10
+ * --out docs/audits/a11y-audit/axe-results --routes /,/dashboard [--width 1440]
11
+ *
12
+ * Requires "playwright" and "axe-core" (see package.json optionalDependencies).
13
+ *
14
+ * Baseline Score weighting (Appfire Baseline Accessibility Audit Process):
15
+ * Critical x3 (axe impact: critical), Major x2 (serious), Minor x1 (moderate, minor).
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { createRequire } from 'node:module';
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ function parseArgs(argv) {
27
+ const opts = { url: null, out: null, routes: ['/'], width: 1440, height: 900 };
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i];
30
+ if (arg === '--url') opts.url = argv[++i];
31
+ else if (arg === '--out') opts.out = argv[++i];
32
+ else if (arg === '--routes') opts.routes = argv[++i].split(',').map((r) => r.trim()).filter(Boolean);
33
+ else if (arg === '--width') opts.width = parseInt(argv[++i], 10) || 1440;
34
+ else if (arg === '--height') opts.height = parseInt(argv[++i], 10) || 900;
35
+ }
36
+ return opts;
37
+ }
38
+
39
+ function routeToSlug(route) {
40
+ return route === '/' ? 'root' : route.replace(/^\//, '').replace(/[/?#]+/g, '-');
41
+ }
42
+
43
+ // Appfire severity mapping: axe impact -> Critical / Major / Minor
44
+ function toAppfireSeverity(impact) {
45
+ if (impact === 'critical') return 'Critical';
46
+ if (impact === 'serious') return 'Major';
47
+ return 'Minor'; // moderate, minor, null
48
+ }
49
+
50
+ const WEIGHTS = { Critical: 3, Major: 2, Minor: 1 };
51
+
52
+ async function main() {
53
+ const { url, out, routes, width, height } = parseArgs(process.argv.slice(2));
54
+
55
+ if (!url || !out) {
56
+ console.error('[a11y-audit-scan] Usage: --url <base-url> --out <dir> [--routes /,/foo] [--width 1440] [--height 900]');
57
+ process.exit(1);
58
+ }
59
+
60
+ let chromium;
61
+ try {
62
+ ({ chromium } = await import('playwright'));
63
+ } catch {
64
+ console.error('[a11y-audit-scan] "playwright" is not installed. Run: npm install -D playwright && npx playwright install chromium');
65
+ process.exit(1);
66
+ }
67
+
68
+ let axeSource;
69
+ try {
70
+ axeSource = fs.readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
71
+ } catch {
72
+ console.error('[a11y-audit-scan] "axe-core" is not installed. Run: npm install -D axe-core');
73
+ process.exit(1);
74
+ }
75
+
76
+ fs.mkdirSync(out, { recursive: true });
77
+
78
+ const browser = await chromium.launch();
79
+ const page = await browser.newPage({ viewport: { width, height } });
80
+
81
+ const summary = { baseUrl: url, scannedAt: new Date().toISOString(), viewport: { width, height }, screens: [], totals: { violations: 0, Critical: 0, Major: 0, Minor: 0 }, baselineScore: 0 };
82
+
83
+ for (const route of routes) {
84
+ const target = new URL(route, url).toString();
85
+ const slug = routeToSlug(route);
86
+ try {
87
+ await page.goto(target, { waitUntil: 'networkidle', timeout: 20000 });
88
+ await page.addScriptTag({ content: axeSource });
89
+ const results = await page.evaluate(async () => {
90
+ // eslint-disable-next-line no-undef
91
+ return await axe.run(document, { resultTypes: ['violations', 'incomplete'] });
92
+ });
93
+
94
+ const counts = { Critical: 0, Major: 0, Minor: 0 };
95
+ const violations = results.violations.map((v) => {
96
+ const severity = toAppfireSeverity(v.impact);
97
+ counts[severity] += v.nodes.length || 1;
98
+ return {
99
+ id: v.id,
100
+ impact: v.impact,
101
+ appfireSeverity: severity,
102
+ wcagTags: v.tags.filter((t) => /^wcag/.test(t)),
103
+ help: v.help,
104
+ helpUrl: v.helpUrl,
105
+ nodes: v.nodes.slice(0, 10).map((n) => ({ target: n.target, html: String(n.html).slice(0, 300), failureSummary: n.failureSummary })),
106
+ nodeCount: v.nodes.length,
107
+ };
108
+ });
109
+
110
+ const screen = {
111
+ route,
112
+ url: target,
113
+ file: `${slug}.json`,
114
+ violations: violations.length,
115
+ nodesAffected: violations.reduce((s, v) => s + v.nodeCount, 0),
116
+ ...counts,
117
+ needsReview: results.incomplete.length,
118
+ score: counts.Critical * WEIGHTS.Critical + counts.Major * WEIGHTS.Major + counts.Minor * WEIGHTS.Minor,
119
+ };
120
+
121
+ fs.writeFileSync(path.join(out, `${slug}.json`), JSON.stringify({ ...screen, details: violations, incomplete: results.incomplete.map((v) => ({ id: v.id, help: v.help, nodeCount: v.nodes.length })) }, null, 2));
122
+
123
+ summary.screens.push(screen);
124
+ summary.totals.violations += screen.violations;
125
+ summary.totals.Critical += counts.Critical;
126
+ summary.totals.Major += counts.Major;
127
+ summary.totals.Minor += counts.Minor;
128
+ summary.baselineScore += screen.score;
129
+
130
+ console.log(`[a11y-audit-scan] ${route}: ${screen.violations} violations (C:${counts.Critical} M:${counts.Major} m:${counts.Minor}, needs-review:${screen.needsReview}) -> ${slug}.json`);
131
+ } catch (err) {
132
+ summary.screens.push({ route, url: target, error: err.message });
133
+ console.error(`[a11y-audit-scan] failed ${route}: ${err.message}`);
134
+ }
135
+ }
136
+
137
+ await browser.close();
138
+
139
+ fs.writeFileSync(path.join(out, 'summary.json'), JSON.stringify(summary, null, 2));
140
+ console.log(`[a11y-audit-scan] Baseline Score: ${summary.baselineScore} (Critical x3, Major x2, Minor x1) -> summary.json`);
141
+
142
+ const failed = summary.screens.filter((s) => s.error);
143
+ if (failed.length === summary.screens.length) {
144
+ console.error('[a11y-audit-scan] all routes failed — STOP & ASK or fall back to static-only.');
145
+ process.exit(1);
146
+ }
147
+ process.exit(0);
148
+ }
149
+
150
+ main();
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Convert audit Markdown reports to Confluence-ready HTML.
4
+ * Images stay as relative assets/*.png — never base64.
5
+ *
6
+ * Usage:
7
+ * node build-html-reports.mjs --file docs/audits/ux-audit/UX-AUDIT-2026-07-02.md
8
+ * node build-html-reports.mjs --dir docs/audits/ux-audit
9
+ * node build-html-reports.mjs --dir docs/audits
10
+ *
11
+ * Bundled path: node_modules/@appfire-ux/audit-agent/scripts/build-html-reports.mjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ function parseArgs(argv) {
20
+ const opts = { files: [], dirs: [] };
21
+ for (let i = 0; i < argv.length; i++) {
22
+ const arg = argv[i];
23
+ if (arg === '--file') opts.files.push(argv[++i]);
24
+ else if (arg === '--dir') opts.dirs.push(argv[++i]);
25
+ }
26
+ return opts;
27
+ }
28
+
29
+ function escapeHtml(text) {
30
+ return text
31
+ .replace(/&/g, '&amp;')
32
+ .replace(/</g, '&lt;')
33
+ .replace(/>/g, '&gt;')
34
+ .replace(/"/g, '&quot;');
35
+ }
36
+
37
+ function inlineFormat(text) {
38
+ let out = escapeHtml(text);
39
+ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
40
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
41
+ out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
42
+ return out;
43
+ }
44
+
45
+ function mdToHtml(md) {
46
+ const lines = md.replace(/\r\n/g, '\n').split('\n');
47
+ const html = [];
48
+ let inCode = false;
49
+ let codeBuf = [];
50
+ let inTable = false;
51
+ let tableRows = [];
52
+
53
+ function flushTable() {
54
+ if (!tableRows.length) return;
55
+ html.push('<table>');
56
+ tableRows.forEach((row, idx) => {
57
+ const tag = idx === 0 ? 'th' : 'td';
58
+ const cells = row.split('|').map((c) => c.trim()).filter(Boolean);
59
+ if (cells.length === 0) return;
60
+ if (idx === 1 && cells.every((c) => /^[-:]+$/.test(c))) return;
61
+ html.push('<tr>' + cells.map((c) => `<${tag}>${inlineFormat(c)}</${tag}>`).join('') + '</tr>');
62
+ });
63
+ html.push('</table>');
64
+ tableRows = [];
65
+ inTable = false;
66
+ }
67
+
68
+ for (const line of lines) {
69
+ if (line.startsWith('```')) {
70
+ if (inCode) {
71
+ html.push('<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>');
72
+ codeBuf = [];
73
+ inCode = false;
74
+ } else {
75
+ flushTable();
76
+ inCode = true;
77
+ }
78
+ continue;
79
+ }
80
+ if (inCode) {
81
+ codeBuf.push(line);
82
+ continue;
83
+ }
84
+
85
+ if (line.trim().startsWith('|')) {
86
+ inTable = true;
87
+ tableRows.push(line.trim());
88
+ continue;
89
+ }
90
+ if (inTable) flushTable();
91
+
92
+ const img = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/);
93
+ if (img) {
94
+ html.push(`<figure><img src="${img[2]}" alt="${escapeHtml(img[1])}" /><figcaption>${escapeHtml(img[1])}</figcaption></figure>`);
95
+ continue;
96
+ }
97
+
98
+ if (/^#{1,6}\s/.test(line)) {
99
+ const level = line.match(/^#+/)[0].length;
100
+ const text = line.replace(/^#+\s*/, '');
101
+ html.push(`<h${level}>${inlineFormat(text)}</h${level}>`);
102
+ continue;
103
+ }
104
+
105
+ if (/^[-*]\s/.test(line)) {
106
+ html.push(`<li>${inlineFormat(line.replace(/^[-*]\s/, ''))}</li>`);
107
+ continue;
108
+ }
109
+
110
+ if (/^\d+\.\s/.test(line)) {
111
+ html.push(`<li>${inlineFormat(line.replace(/^\d+\.\s/, ''))}</li>`);
112
+ continue;
113
+ }
114
+
115
+ if (line.trim() === '---') {
116
+ html.push('<hr />');
117
+ continue;
118
+ }
119
+
120
+ if (line.trim() === '') {
121
+ html.push('');
122
+ continue;
123
+ }
124
+
125
+ html.push(`<p>${inlineFormat(line)}</p>`);
126
+ }
127
+
128
+ if (inTable) flushTable();
129
+ if (inCode && codeBuf.length) {
130
+ html.push('<pre><code>' + escapeHtml(codeBuf.join('\n')) + '</code></pre>');
131
+ }
132
+
133
+ return html.join('\n');
134
+ }
135
+
136
+ function listPngs(assetsDir) {
137
+ if (!fs.existsSync(assetsDir)) return [];
138
+ return fs
139
+ .readdirSync(assetsDir)
140
+ .filter((f) => f.endsWith('.png'))
141
+ .sort()
142
+ .map((f) => `assets/${f}`);
143
+ }
144
+
145
+ function buildGallery(assetsDir) {
146
+ const images = listPngs(assetsDir);
147
+ if (!images.length) return '';
148
+ const items = images
149
+ .map((src) => `<figure><img src="${src}" alt="${path.basename(src)}" /><figcaption>${escapeHtml(path.basename(src))}</figcaption></figure>`)
150
+ .join('\n');
151
+ return `<section class="gallery"><h2>Visual evidence gallery</h2>\n${items}\n</section>`;
152
+ }
153
+
154
+ function wrapHtml(title, body, gallery) {
155
+ return `<!DOCTYPE html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="utf-8" />
159
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
160
+ <title>${escapeHtml(title)}</title>
161
+ <style>
162
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; line-height: 1.5; max-width: 960px; margin: 0 auto; padding: 24px; color: #172b4d; }
163
+ .confluence-hint { background: #deebff; border-left: 4px solid #0052cc; padding: 12px 16px; margin-bottom: 24px; font-size: 14px; }
164
+ h1, h2, h3 { line-height: 1.25; }
165
+ table { border-collapse: collapse; width: 100%; margin: 16px 0; }
166
+ th, td { border: 1px solid #dfe1e6; padding: 8px 12px; text-align: left; vertical-align: top; }
167
+ th { background: #f4f5f7; }
168
+ img { max-width: 100%; height: auto; border: 1px solid #dfe1e6; border-radius: 3px; }
169
+ figure { margin: 16px 0; }
170
+ figcaption { font-size: 12px; color: #5e6c84; margin-top: 4px; }
171
+ pre { background: #f4f5f7; padding: 12px; overflow-x: auto; border-radius: 3px; }
172
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
173
+ .gallery { margin-top: 48px; padding-top: 24px; border-top: 2px solid #dfe1e6; }
174
+ .gallery figure { display: inline-block; width: 48%; margin: 1%; vertical-align: top; }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <div class="confluence-hint">
179
+ <strong>Confluence:</strong> Open this file in a browser → <kbd>⌘A</kbd> / <kbd>Ctrl+A</kbd> → Copy → Paste into Confluence.
180
+ Images load from the local <code>assets/</code> folder — keep PNG files alongside this HTML when sharing.
181
+ </div>
182
+ <main>
183
+ ${body}
184
+ </main>
185
+ ${gallery}
186
+ </body>
187
+ </html>`;
188
+ }
189
+
190
+ function convertFile(mdPath) {
191
+ const abs = path.resolve(mdPath);
192
+ if (!fs.existsSync(abs)) {
193
+ console.error(`[build-html-reports] not found: ${abs}`);
194
+ return false;
195
+ }
196
+ const md = fs.readFileSync(abs, 'utf8');
197
+ const dir = path.dirname(abs);
198
+ const base = path.basename(abs, '.md');
199
+ const assetsDir = path.join(dir, 'assets');
200
+ const title = base;
201
+ const body = mdToHtml(md);
202
+ const gallery = buildGallery(assetsDir);
203
+ const html = wrapHtml(title, body, gallery);
204
+ const outPath = path.join(dir, `${base}.html`);
205
+ fs.writeFileSync(outPath, html);
206
+ console.log(`[build-html-reports] ${outPath}`);
207
+ return true;
208
+ }
209
+
210
+ function collectMdFiles(dir) {
211
+ const abs = path.resolve(dir);
212
+ if (!fs.existsSync(abs)) return [];
213
+ const results = [];
214
+ for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
215
+ const full = path.join(abs, entry.name);
216
+ if (entry.isDirectory()) {
217
+ if (entry.name === 'assets' || entry.name === 'node_modules') continue;
218
+ results.push(...collectMdFiles(full));
219
+ } else if (entry.isFile() && entry.name.endsWith('.md') && /AUDIT/i.test(entry.name)) {
220
+ results.push(full);
221
+ }
222
+ }
223
+ return results;
224
+ }
225
+
226
+ function main() {
227
+ const { files, dirs } = parseArgs(process.argv.slice(2));
228
+ const targets = [...files];
229
+ for (const d of dirs) targets.push(...collectMdFiles(d));
230
+
231
+ if (!targets.length) {
232
+ console.error('[build-html-reports] Usage: --file <path.md> | --dir <folder>');
233
+ process.exit(1);
234
+ }
235
+
236
+ let ok = 0;
237
+ for (const f of [...new Set(targets)]) {
238
+ if (convertFile(f)) ok++;
239
+ }
240
+ if (ok === 0) process.exit(1);
241
+ }
242
+
243
+ main();
@@ -7,7 +7,7 @@
7
7
  * Usage:
8
8
  * npx playwright install chromium # once, if not already installed
9
9
  * node scripts/ux-audit-capture.mjs --url http://localhost:5173 \
10
- * --out docs/ux-audit/assets --routes /,/dashboard/teams
10
+ * --out docs/ux-audit/assets --routes /,/dashboard/teams --widths 1440,768
11
11
  *
12
12
  * Requires the optional "playwright" dependency (see package.json).
13
13
  */
@@ -18,15 +18,17 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
 
20
20
  function parseArgs(argv) {
21
- const opts = { url: null, out: null, routes: ['/'], width: 1440, height: 900 };
21
+ const opts = { url: null, out: null, routes: ['/'], widths: [1440], height: 900 };
22
22
  for (let i = 0; i < argv.length; i++) {
23
23
  const arg = argv[i];
24
24
  if (arg === '--url') opts.url = argv[++i];
25
25
  else if (arg === '--out') opts.out = argv[++i];
26
26
  else if (arg === '--routes') opts.routes = argv[++i].split(',').map((r) => r.trim()).filter(Boolean);
27
- else if (arg === '--width') opts.width = parseInt(argv[++i], 10) || 1440;
27
+ else if (arg === '--width') opts.widths = [parseInt(argv[++i], 10) || 1440];
28
+ else if (arg === '--widths') opts.widths = argv[++i].split(',').map((w) => parseInt(w.trim(), 10)).filter(Boolean);
28
29
  else if (arg === '--height') opts.height = parseInt(argv[++i], 10) || 900;
29
30
  }
31
+ if (!opts.widths.length) opts.widths = [1440];
30
32
  return opts;
31
33
  }
32
34
 
@@ -36,10 +38,10 @@ function routeToFilename(route, width) {
36
38
  }
37
39
 
38
40
  async function main() {
39
- const { url, out, routes, width, height } = parseArgs(process.argv.slice(2));
41
+ const { url, out, routes, widths, height } = parseArgs(process.argv.slice(2));
40
42
 
41
43
  if (!url || !out) {
42
- console.error('[ux-audit-capture] Usage: --url <base-url> --out <dir> [--routes /,/foo,/bar] [--width 1440] [--height 900]');
44
+ console.error('[ux-audit-capture] Usage: --url <base-url> --out <dir> [--routes /,/foo] [--width 1440 | --widths 1440,768] [--height 900]');
43
45
  process.exit(1);
44
46
  }
45
47
 
@@ -54,21 +56,24 @@ async function main() {
54
56
  fs.mkdirSync(out, { recursive: true });
55
57
 
56
58
  const browser = await chromium.launch();
57
- const page = await browser.newPage({ viewport: { width, height } });
58
-
59
59
  const results = [];
60
- for (const route of routes) {
61
- const target = new URL(route, url).toString();
62
- const file = path.join(out, routeToFilename(route, width));
63
- try {
64
- await page.goto(target, { waitUntil: 'networkidle', timeout: 15000 });
65
- await page.screenshot({ path: file, fullPage: true });
66
- results.push({ route, file, ok: true });
67
- console.log(`[ux-audit-capture] captured ${route} -> ${file}`);
68
- } catch (err) {
69
- results.push({ route, file: null, ok: false, error: err.message });
70
- console.error(`[ux-audit-capture] failed ${route}: ${err.message}`);
60
+
61
+ for (const width of widths) {
62
+ const page = await browser.newPage({ viewport: { width, height } });
63
+ for (const route of routes) {
64
+ const target = new URL(route, url).toString();
65
+ const file = path.join(out, routeToFilename(route, width));
66
+ try {
67
+ await page.goto(target, { waitUntil: 'networkidle', timeout: 15000 });
68
+ await page.screenshot({ path: file, fullPage: true });
69
+ results.push({ route, width, file, ok: true });
70
+ console.log(`[ux-audit-capture] captured ${route} @${width}w -> ${file}`);
71
+ } catch (err) {
72
+ results.push({ route, width, file: null, ok: false, error: err.message });
73
+ console.error(`[ux-audit-capture] failed ${route} @${width}w: ${err.message}`);
74
+ }
71
75
  }
76
+ await page.close();
72
77
  }
73
78
 
74
79
  await browser.close();