@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 +18 -11
- package/bin/install.js +3 -2
- package/package.json +8 -3
- package/scripts/a11y-audit-scan.mjs +150 -0
- package/scripts/build-html-reports.mjs +243 -0
- package/scripts/ux-audit-capture.mjs +23 -18
- package/templates/agents/a11y-auditor.md +167 -0
- package/templates/agents/ui-auditor.md +58 -29
- package/templates/agents/ux-auditor.md +131 -22
- package/templates/commands/a11y-audit.md +22 -0
- package/templates/commands/full-audit.md +85 -49
- package/templates/commands/ui-audit.md +7 -4
- package/templates/commands/ux-audit.md +6 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @appfire-ux/audit-agent
|
|
2
2
|
|
|
3
|
-
One-command install of
|
|
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 #
|
|
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` |
|
|
50
|
-
| `.claude/commands/ux-audit.md` | `/ux-audit [scope] [--static-only] [--lang pl] [--out <path>]` |
|
|
51
|
-
| `.claude/agents/ui-auditor.md` |
|
|
52
|
-
| `.claude/commands/ui-audit.md` | `/ui-audit [scope] [--static-only] [--lang pl] [--out <path>]` |
|
|
53
|
-
| `.claude/
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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>]
|
|
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.
|
|
4
|
-
"description": "Claude Code audit agents for Appfire apps —
|
|
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, '&')
|
|
32
|
+
.replace(/</g, '<')
|
|
33
|
+
.replace(/>/g, '>')
|
|
34
|
+
.replace(/"/g, '"');
|
|
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: ['/'],
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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();
|