qa-engineer 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +167 -59
  2. package/package.json +1 -1
  3. package/packages/engine/bin/qa-engine.mjs +232 -8
  4. package/packages/engine/lib/analysis/har.mjs +34 -0
  5. package/packages/engine/lib/analysis/network.mjs +237 -0
  6. package/packages/engine/lib/analysis/report-html.mjs +47 -734
  7. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  8. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  9. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  10. package/packages/engine/lib/report/components/charts.mjs +424 -0
  11. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  12. package/packages/engine/lib/report/components/findings.mjs +258 -0
  13. package/packages/engine/lib/report/components/nav.mjs +99 -0
  14. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  15. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  16. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  17. package/packages/engine/lib/report/core/model.mjs +270 -0
  18. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  19. package/packages/engine/lib/report/core/sections.mjs +978 -0
  20. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  21. package/packages/engine/lib/report/export/html.mjs +183 -0
  22. package/packages/engine/lib/report/export/machine.mjs +290 -0
  23. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  24. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  25. package/packages/engine/lib/report/theme/css.mjs +529 -0
  26. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  27. package/packages/engine/lib/report/version.mjs +78 -0
  28. package/packages/engine/package.json +2 -2
  29. package/packages/installer/lib/agents/targets.mjs +90 -0
  30. package/packages/installer/lib/agents/user-level.mjs +80 -0
  31. package/packages/installer/lib/cli/flags.mjs +20 -2
  32. package/packages/installer/lib/commands/doctor.mjs +6 -4
  33. package/packages/installer/lib/commands/install.mjs +134 -93
  34. package/packages/installer/lib/commands/repair.mjs +10 -6
  35. package/packages/installer/lib/commands/self-test.mjs +4 -3
  36. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  37. package/packages/installer/lib/commands/update.mjs +9 -4
  38. package/packages/installer/lib/commands/verify.mjs +13 -12
  39. package/packages/installer/lib/constants.mjs +13 -0
  40. package/packages/installer/lib/core/conflict.mjs +5 -4
  41. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  42. package/packages/installer/lib/core/integrity.mjs +59 -0
  43. package/packages/installer/lib/core/lockfile.mjs +19 -3
  44. package/packages/installer/lib/core/plan.mjs +213 -0
  45. package/packages/installer/lib/core/qa-home.mjs +145 -0
  46. package/packages/installer/lib/core/scope.mjs +274 -0
  47. package/packages/installer/lib/core/validate-install.mjs +37 -12
  48. package/packages/installer/package.json +1 -1
  49. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  50. package/shared/tooling/qa-tool.mjs +41 -5
  51. package/skills/qa-api/scripts/qa-tool.mjs +41 -5
  52. package/skills/qa-audit/scripts/qa-tool.mjs +41 -5
  53. package/skills/qa-debug/scripts/qa-tool.mjs +41 -5
  54. package/skills/qa-explore/SKILL.md +26 -10
  55. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  56. package/skills/qa-explore/references/api-replay.md +40 -1
  57. package/skills/qa-explore/references/report-pipeline.md +265 -95
  58. package/skills/qa-explore/scripts/qa-tool.mjs +41 -5
  59. package/skills/qa-fix/scripts/qa-tool.mjs +41 -5
  60. package/skills/qa-flaky/scripts/qa-tool.mjs +41 -5
  61. package/skills/qa-init/scripts/qa-tool.mjs +41 -5
  62. package/skills/qa-report/scripts/qa-tool.mjs +41 -5
  63. package/skills/qa-run/scripts/qa-tool.mjs +41 -5
@@ -0,0 +1,207 @@
1
+ // Rendering evidence: screenshots, video, traces, logs — and honest absence.
2
+ //
3
+ // The contract for this module is one sentence: **it never emits a broken link.**
4
+ //
5
+ // Every path here has already been through the artifact registry, so each record
6
+ // carries `exists` as a measured fact. A record that is present renders as a preview
7
+ // with actions; a record that is not renders as a stated absence with the reason and
8
+ // the path that was searched. What it must never do is emit `<img src=…>` for a file
9
+ // nobody checked, because the browser's broken-image glyph tells the reader nothing
10
+ // except that the report is unreliable.
11
+
12
+ import { e, icon, chip, formatBytes } from './primitives.mjs';
13
+
14
+ // What each evidence kind is called in the report. The contract guarantees `type` on
15
+ // every entry but a caption only sometimes, so the type label is what a caption can
16
+ // always fall back to.
17
+ export const EVIDENCE_LABEL = Object.freeze({
18
+ screenshot: 'Screenshot',
19
+ video: 'Video',
20
+ trace: 'Playwright trace',
21
+ network: 'Network capture',
22
+ console: 'Console output',
23
+ dom: 'DOM snapshot',
24
+ har: 'HAR archive',
25
+ db: 'Database query',
26
+ performance: 'Performance metrics',
27
+ accessibility: 'Accessibility report',
28
+ security: 'Security report',
29
+ api: 'API report',
30
+ coverage: 'Coverage report',
31
+ log: 'Log',
32
+ diff: 'Diff',
33
+ file: 'File',
34
+ report: 'Report',
35
+ command: 'Command output',
36
+ });
37
+
38
+ export function evidenceLabel(kind) {
39
+ return EVIDENCE_LABEL[kind] ?? 'Evidence';
40
+ }
41
+
42
+ /**
43
+ * A stated absence.
44
+ *
45
+ * Named rather than silent: a missing screenshot is itself a finding about the run,
46
+ * and hiding it lets a reader assume the evidence exists and they simply cannot see
47
+ * it. The recorded path is shown because it is the one thing that makes the problem
48
+ * fixable — nine times in ten it is a path written relative to the wrong directory.
49
+ */
50
+ export function missingArtifact(record) {
51
+ const name = record.label ?? evidenceLabel(record.kind);
52
+ const reason = record.missingReason ?? 'File not found at the recorded path';
53
+ const path = record.declaredPath
54
+ ? `<div style="margin-top:.25rem"><code>${e(record.declaredPath)}</code></div>`
55
+ : '';
56
+ return (
57
+ '<div class="artifact-missing" role="note">' +
58
+ icon('warning', 18) +
59
+ `<div><span class="why">Artifact missing — ${e(name)}</span>` +
60
+ `<span>${e(reason)}.</span>${path}</div>` +
61
+ '</div>'
62
+ );
63
+ }
64
+
65
+ /** The caption strip under a preview: what it is, how big, and where to open it. */
66
+ function caption(record, { side = null } = {}) {
67
+ const name = record.label ?? evidenceLabel(record.kind);
68
+ const bits = [
69
+ `<span class="name"${side ? ` data-side="${e(side)}"` : ''}>${e(name)}</span>`,
70
+ record.sizeLabel ? `<span class="muted">${e(record.sizeLabel)}</span>` : '',
71
+ record.declaredPath ? `<code>${e(record.declaredPath)}</code>` : '',
72
+ ].filter(Boolean);
73
+
74
+ const actions = record.href
75
+ ? '<span class="shot-actions">' +
76
+ `<a href="${e(record.href)}" target="_blank" rel="noopener noreferrer">Open full size</a>` +
77
+ '</span>'
78
+ : '';
79
+ return `<figcaption>${bits.join('')}${actions}</figcaption>`;
80
+ }
81
+
82
+ /**
83
+ * One artifact, rendered the way its type allows.
84
+ *
85
+ * Images get a lazy-loaded preview that opens in the lightbox; video gets native
86
+ * controls; everything else gets a link and, when the run captured one, a text
87
+ * excerpt. `loading="lazy"` and explicit dimensions matter here — a report with forty
88
+ * screenshots that decodes all of them on open is a report nobody scrolls.
89
+ */
90
+ export function artifactFigure(record, { side = null, excerpt = null } = {}) {
91
+ if (!record) return '';
92
+ if (!record.exists) return missingArtifact(record);
93
+
94
+ if (record.renderAs === 'image') {
95
+ // An embedded copy wins: it is the one that still shows when the report travels
96
+ // alone. The full-size link stays pointed at the file on disk.
97
+ const src = record.dataUri ?? record.thumbnailHref ?? record.href;
98
+ const dimensions =
99
+ record.width && record.height ? ` width="${record.width}" height="${record.height}"` : '';
100
+ return (
101
+ '<figure class="shot">' +
102
+ `<img src="${e(src)}" alt="${e(record.label ?? evidenceLabel(record.kind))}" loading="lazy" ` +
103
+ `decoding="async"${dimensions} data-full="${e(record.dataUri ?? record.href)}" ` +
104
+ `data-name="${e(record.label ?? record.declaredPath)}" class="js-zoom">` +
105
+ caption(record, { side }) +
106
+ '</figure>'
107
+ );
108
+ }
109
+
110
+ if (record.renderAs === 'video') {
111
+ return (
112
+ '<figure class="shot">' +
113
+ `<video controls preload="metadata" src="${e(record.href)}"></video>` +
114
+ caption(record, { side }) +
115
+ '</figure>'
116
+ );
117
+ }
118
+
119
+ // A trace, a HAR, a log. The excerpt is what makes it readable without leaving the
120
+ // page; the link is what makes it verifiable.
121
+ return (
122
+ '<figure class="shot">' +
123
+ (excerpt ? `<pre><code>${e(excerpt)}</code></pre>` : '') +
124
+ caption(record, { side }) +
125
+ '</figure>'
126
+ );
127
+ }
128
+
129
+ /**
130
+ * A before/after pair, shown side by side.
131
+ *
132
+ * Two screenshots stacked vertically are two screenshots; side by side with a shared
133
+ * caption they are a comparison, and the reader sees the difference without being
134
+ * told what to look for.
135
+ */
136
+ export function comparePair(before, after) {
137
+ if (!before || !after) return '';
138
+ return (
139
+ '<div class="compare">' +
140
+ artifactFigure(before, { side: 'Before' }) +
141
+ artifactFigure(after, { side: 'After' }) +
142
+ '</div>'
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Every evidence entry on a finding, resolved and rendered.
148
+ *
149
+ * Before/after pairs are pulled out first so they render together rather than as two
150
+ * unrelated tiles; whatever is left renders in declaration order.
151
+ */
152
+ export function evidenceGrid(entries, registry) {
153
+ const records = (entries ?? [])
154
+ .map((entry) => ({ entry, record: registry.forEvidence(entry) }))
155
+ .filter((pair) => pair.record);
156
+ if (records.length === 0) return '';
157
+
158
+ const rendered = [];
159
+ const consumed = new Set();
160
+
161
+ for (const { entry, record } of records) {
162
+ if (consumed.has(record)) continue;
163
+ if (record.compares) {
164
+ const other = registry.get(record.compares);
165
+ if (other) {
166
+ consumed.add(record);
167
+ consumed.add(other);
168
+ // `compares` points at the earlier state, so the record declaring it is "after".
169
+ rendered.push(comparePair(other, record));
170
+ continue;
171
+ }
172
+ }
173
+ consumed.add(record);
174
+ rendered.push(artifactFigure(record, { excerpt: entry.excerpt ?? null }));
175
+ }
176
+
177
+ return `<div class="evidence">${rendered.join('')}</div>`;
178
+ }
179
+
180
+ /** The run-level evidence index: one table of every artifact and whether it is there. */
181
+ export function artifactTable(records) {
182
+ if (records.length === 0) return '';
183
+ const rows = records
184
+ .map((record) => {
185
+ const state = record.exists
186
+ ? '<span class="pill st-passed">Present</span>'
187
+ : '<span class="pill st-failed">Missing</span>';
188
+ const link = record.exists && record.href
189
+ ? `<a href="${e(record.href)}" target="_blank" rel="noopener noreferrer"><code>${e(record.declaredPath)}</code></a>`
190
+ : `<code>${e(record.declaredPath || '—')}</code>`;
191
+ return (
192
+ '<tr>' +
193
+ `<td>${e(evidenceLabel(record.kind))}</td>` +
194
+ `<td>${e(record.label ?? '')}</td>` +
195
+ `<td>${link}</td>` +
196
+ `<td class="num">${e(record.exists ? formatBytes(record.bytes) : '—')}</td>` +
197
+ `<td>${state}${record.hashMismatch ? chip('hash mismatch') : ''}</td>` +
198
+ '</tr>'
199
+ );
200
+ })
201
+ .join('');
202
+ return (
203
+ '<div class="table-wrap"><table><thead><tr>' +
204
+ '<th>Kind</th><th>Shows</th><th>File</th><th class="num">Size</th><th>State</th>' +
205
+ `</tr></thead><tbody>${rows}</tbody></table></div>`
206
+ );
207
+ }
@@ -0,0 +1,258 @@
1
+ // The finding card — the unit of the report a developer actually works from.
2
+ //
3
+ // ## Collapsed by default, and why the summary has to carry weight
4
+ //
5
+ // A report with twenty findings expanded is a wall nobody reads. Collapsed, the
6
+ // summary row is all most readers ever see, so it carries the four things needed to
7
+ // triage without opening anything: severity, what is wrong, where, and whether it was
8
+ // confirmed. Everything else lives inside.
9
+ //
10
+ // `<details>` rather than a JavaScript accordion: it opens with no script, it is
11
+ // keyboard-operable and screen-reader-announced for free, and Ctrl-F finds text inside
12
+ // a closed one in Chrome. The print stylesheet forces every one open, because a
13
+ // printed report of collapsed headings is a printed table of contents.
14
+ //
15
+ // ## The order of the body
16
+ //
17
+ // what happens now → what should happen → why it matters → how to see it
18
+ // → why it happens → how to fix it → what to re-test → the proof
19
+ //
20
+ // A reader who stops one third of the way down still has the defect, the gap, and the
21
+ // business consequence. Root cause and fix direction come after that, because they are
22
+ // what an engineer needs and the first three are what everyone needs.
23
+
24
+ import {
25
+ e, slug, severityBadge, statusPill, chip, icon, plural,
26
+ } from './primitives.mjs';
27
+ import { evidenceGrid } from './evidence.mjs';
28
+ import { SEVERITY } from '../theme/tokens.mjs';
29
+
30
+ const DIMENSION_LABEL = Object.freeze({
31
+ functional: 'Functionality',
32
+ api: 'API',
33
+ performance: 'Performance',
34
+ security: 'Security',
35
+ ui: 'UI',
36
+ ux: 'UX',
37
+ data: 'Data',
38
+ accessibility: 'Accessibility',
39
+ console: 'Console',
40
+ });
41
+
42
+ const STATUS_LABEL = Object.freeze({
43
+ confirmed: 'Confirmed',
44
+ 'validated-user-report': 'Validated user report',
45
+ 'could-not-reproduce': 'Could not reproduce',
46
+ partial: 'Partially reproduced',
47
+ 'fixed-in-run': 'Fixed during the run',
48
+ });
49
+
50
+ const LAYER_LABEL = Object.freeze({
51
+ frontend: 'Frontend',
52
+ backend: 'Backend',
53
+ network: 'Network',
54
+ data: 'Data',
55
+ infrastructure: 'Infrastructure',
56
+ 'third-party': 'Third party',
57
+ unknown: 'Unknown',
58
+ });
59
+
60
+ export function dimensionLabel(dimension) {
61
+ return DIMENSION_LABEL[dimension] ?? dimension ?? '';
62
+ }
63
+
64
+ export function findingAnchor(id) {
65
+ return slug(id, 'f-');
66
+ }
67
+
68
+ /**
69
+ * Reproduction as steps when the run recorded steps, as prose otherwise.
70
+ *
71
+ * `steps[]` is the contract's structured form and is used when present. Failing that,
72
+ * numbered prose is split on its own numbering — nothing invented, nothing reordered.
73
+ * Unnumbered prose is left exactly as written, because guessing sentence boundaries in
74
+ * a repro turns "click Save. Wait 3s." into two steps that are really one.
75
+ */
76
+ function reproBlock(finding) {
77
+ if (Array.isArray(finding.steps) && finding.steps.length > 0) {
78
+ return `<ol class="steps">${finding.steps.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`;
79
+ }
80
+ const text = String(finding.repro ?? '').trim();
81
+ if (!text) return '<span class="empty">Not recorded</span>';
82
+
83
+ let lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
84
+ if (lines.length === 1) {
85
+ lines = text.split(/\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter(Boolean);
86
+ }
87
+ if (lines.length < 2 || !lines.every((line) => /^\d+[.)]\s/.test(line))) {
88
+ return e(text);
89
+ }
90
+ const steps = lines.map((line) => line.replace(/^\d+[.)]\s*/, ''));
91
+ return `<ol class="steps">${steps.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`;
92
+ }
93
+
94
+ /** A labelled row in the behaviour list. */
95
+ function row(term, body, railClass = null) {
96
+ if (!body) return '';
97
+ return `<dt>${e(term)}</dt><dd${railClass ? ` class="rail ${railClass}"` : ''}>${body}</dd>`;
98
+ }
99
+
100
+ /** The metrics table that turns "slow" into a number with a budget beside it. */
101
+ function metricsPanel(metrics) {
102
+ if (!metrics || metrics.length === 0) return '';
103
+ const rows = metrics
104
+ .map(
105
+ (metric) =>
106
+ '<tr>' +
107
+ `<td>${e(metric.label)}</td>` +
108
+ `<td class="num"${metric.breached ? ' style="color:var(--sev-solid);font-weight:650"' : ''}>` +
109
+ `${e(metric.value)}</td>` +
110
+ `<td class="num muted">${e(metric.budget ?? '—')}</td>` +
111
+ '</tr>',
112
+ )
113
+ .join('');
114
+ return (
115
+ '<div class="table-wrap" style="margin-top:1rem"><table>' +
116
+ '<thead><tr><th>Measurement</th><th class="num">Observed</th><th class="num">Budget</th></tr></thead>' +
117
+ `<tbody>${rows}</tbody></table></div>`
118
+ );
119
+ }
120
+
121
+ /** Root cause: the investigation, rendered as a causal chain rather than a paragraph. */
122
+ function rootCausePanel(rootCause) {
123
+ if (!rootCause?.summary) return '';
124
+ const chain =
125
+ Array.isArray(rootCause.chain) && rootCause.chain.length > 0
126
+ ? `<ol class="chain" style="margin-top:.5rem">${rootCause.chain.map((step) => `<li>${e(step)}</li>`).join('')}</ol>`
127
+ : '';
128
+ const meta = [
129
+ rootCause.layer ? chip(LAYER_LABEL[rootCause.layer] ?? rootCause.layer) : '',
130
+ Number.isFinite(rootCause.confidence)
131
+ ? chip(`${Math.round(rootCause.confidence * 100)}% confidence`)
132
+ : '',
133
+ ]
134
+ .filter(Boolean)
135
+ .join('');
136
+ return (
137
+ `<div>${e(rootCause.summary)}${chain}` +
138
+ (meta ? `<div style="margin-top:.5rem;display:flex;gap:.375rem;flex-wrap:wrap">${meta}</div>` : '') +
139
+ '</div>'
140
+ );
141
+ }
142
+
143
+ /** What QA re-runs once the fix lands — the half of a bug report that usually goes missing. */
144
+ function regressionPanel(regression) {
145
+ if (!regression?.level) return '';
146
+ const tone = { high: 'critical', medium: 'medium', low: 'low' }[regression.level] ?? 'low';
147
+ const retest =
148
+ Array.isArray(regression.retest) && regression.retest.length > 0
149
+ ? `<ul class="clean" style="margin-top:.5rem">${regression.retest.map((item) => `<li>${e(item)}</li>`).join('')}</ul>`
150
+ : '';
151
+ return (
152
+ `<div><span class="badge sev-${tone}">${e(regression.level)} risk</span>` +
153
+ (regression.note ? `<div style="margin-top:.375rem">${e(regression.note)}</div>` : '') +
154
+ retest +
155
+ '</div>'
156
+ );
157
+ }
158
+
159
+ /**
160
+ * One finding, collapsed.
161
+ *
162
+ * `data-*` attributes on the element carry everything the client-side filter needs, so
163
+ * search and severity filtering run without a second copy of the findings in a script
164
+ * block — the DOM is the index.
165
+ */
166
+ export function findingCard(finding, registry, { open = false } = {}) {
167
+ const severity = SEVERITY[finding.severity] ? finding.severity : 'low';
168
+ const anchor = findingAnchor(finding.id);
169
+ const tags = Array.isArray(finding.tags) ? finding.tags : [];
170
+
171
+ // Everything a reader might type into the search box, flattened once at render time.
172
+ const haystack = [
173
+ finding.id, finding.title, finding.actual, finding.expected, finding.fixDirection,
174
+ finding.businessImpact, finding.developerNotes, finding.page,
175
+ finding.rootCause?.summary, dimensionLabel(finding.dimension),
176
+ ...tags, ...(finding.affectedApis ?? []),
177
+ ]
178
+ .filter(Boolean)
179
+ .join(' ')
180
+ .toLowerCase();
181
+
182
+ const meta = [
183
+ `<span class="finding-id">${e(finding.id)}</span>`,
184
+ chip(dimensionLabel(finding.dimension)),
185
+ statusPill(finding.status, STATUS_LABEL[finding.status] ?? finding.status),
186
+ finding.page ? chip(finding.page) : '',
187
+ ...tags.map((tag) => chip(tag)),
188
+ ]
189
+ .filter(Boolean)
190
+ .join('');
191
+
192
+ const body = [
193
+ '<dl class="behaviour">',
194
+ row('Current behaviour', e(finding.actual), 'rail-now'),
195
+ row('Expected behaviour', e(finding.expected), 'rail-want'),
196
+ row('Business impact', finding.businessImpact ? e(finding.businessImpact) : '', 'rail-impact'),
197
+ row('Reproduction', reproBlock(finding)),
198
+ row('Root cause', rootCausePanel(finding.rootCause), 'rail-cause'),
199
+ row('Suggested fix', e(finding.fixDirection), 'rail-fix'),
200
+ row('Regression risk', regressionPanel(finding.regressionRisk)),
201
+ row(
202
+ 'Affected APIs',
203
+ (finding.affectedApis ?? []).map((api) => `<code>${e(api)}</code>`).join(' '),
204
+ ),
205
+ row('Developer notes', finding.developerNotes ? e(finding.developerNotes) : ''),
206
+ '</dl>',
207
+ metricsPanel(finding.metrics),
208
+ evidenceGrid(finding.evidence, registry),
209
+ ].join('');
210
+
211
+ return (
212
+ `<details class="finding sev-${severity}" id="${e(anchor)}"${open ? ' open' : ''} ` +
213
+ `data-severity="${e(severity)}" data-dimension="${e(finding.dimension ?? '')}" ` +
214
+ `data-status="${e(finding.status ?? '')}" data-page="${e(finding.page ?? '')}" ` +
215
+ `data-tags="${e(tags.join(' '))}" data-search="${e(haystack)}">` +
216
+ '<summary>' +
217
+ severityBadge(severity) +
218
+ '<span class="finding-grow">' +
219
+ `<span class="finding-title">${e(finding.title)}</span>` +
220
+ `<span class="finding-meta">${meta}</span>` +
221
+ '</span>' +
222
+ `<span class="caret">${icon('chevron', 18)}</span>` +
223
+ '</summary>' +
224
+ `<div class="finding-body">${body}</div>` +
225
+ '</details>'
226
+ );
227
+ }
228
+
229
+ /** Every finding, worst first, with a stable tie-break so reruns diff cleanly. */
230
+ export function findingList(findings, registry) {
231
+ const sorted = [...findings].sort((a, b) => {
232
+ const rank = (SEVERITY[a.severity] ?? SEVERITY.low).rank - (SEVERITY[b.severity] ?? SEVERITY.low).rank;
233
+ return rank !== 0 ? rank : String(a.id).localeCompare(String(b.id), 'en', { numeric: true });
234
+ });
235
+ if (sorted.length === 0) {
236
+ return '<div class="card"><div class="card-body empty">No findings recorded.</div></div>';
237
+ }
238
+ return (
239
+ sorted.map((finding) => findingCard(finding, registry)).join('') +
240
+ '<div class="no-results" id="no-results">' +
241
+ `<p><strong>Nothing matches those filters.</strong></p><p>${e(plural(sorted.length, 'finding'))} in total — ` +
242
+ 'clear the search box or reset the severity filters to see them.</p></div>'
243
+ );
244
+ }
245
+
246
+ /** The severity legend, so a reader can calibrate without asking what "high" means. */
247
+ export function severityLegend() {
248
+ const rows = Object.entries(SEVERITY)
249
+ .sort((a, b) => a[1].rank - b[1].rank)
250
+ .map(([key, value]) => `<tr><td>${severityBadge(key)}</td><td>${e(value.meaning)}</td></tr>`)
251
+ .join('');
252
+ return (
253
+ '<div class="table-wrap"><table><thead><tr><th>Severity</th><th>What it claims</th></tr></thead>' +
254
+ `<tbody>${rows}</tbody></table></div>`
255
+ );
256
+ }
257
+
258
+ export { STATUS_LABEL, DIMENSION_LABEL };
@@ -0,0 +1,99 @@
1
+ // The sticky sidebar and the findings toolbar.
2
+ //
3
+ // A twelve-section report needs a map, and a report shared with six audiences needs a
4
+ // way for each of them to reach their part of it: an engineering manager wants
5
+ // Findings, a CTO wants Overview and Security, a designer wants Screenshots.
6
+ //
7
+ // Both controls degrade honestly. The sidebar is a list of in-page anchors, so it
8
+ // works with scripting disabled and it prints as nothing (deliberately hidden). The
9
+ // toolbar's search and filters need script, so they are rendered only when the report
10
+ // has enough findings to be worth filtering, and the findings themselves are always
11
+ // present in the document whether or not the toolbar ever runs.
12
+
13
+ import { e, icon } from './primitives.mjs';
14
+ import { SEVERITY, SEVERITY_ORDER } from '../theme/tokens.mjs';
15
+
16
+ /** The sidebar: brand, then one anchor per section that has content. */
17
+ export function sidebar(sections, { productName, subtitle }) {
18
+ const links = sections
19
+ .filter((section) => section.body)
20
+ .map(
21
+ (section) =>
22
+ `<a href="#${e(section.id)}" data-target="${e(section.id)}">` +
23
+ `<span>${e(section.navLabel ?? section.title)}</span>` +
24
+ (section.count === null || section.count === undefined
25
+ ? ''
26
+ : `<span class="n">${e(section.count)}</span>`) +
27
+ '</a>',
28
+ )
29
+ .join('');
30
+
31
+ return (
32
+ '<aside class="sidebar" aria-label="Report sections">' +
33
+ '<div class="sidebar-brand">' +
34
+ '<span class="sidebar-mark" aria-hidden="true">QA</span>' +
35
+ `<span><span class="sidebar-name">${e(productName)}</span>` +
36
+ `<br><span class="sidebar-sub">${e(subtitle)}</span></span>` +
37
+ '</div>' +
38
+ `<nav class="toc" aria-label="Sections">${links}</nav>` +
39
+ '</aside>'
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Search, severity filters, expand/collapse, theme, print.
45
+ *
46
+ * The severity buttons start pressed — the default view is everything, and a filter
47
+ * that hides findings before the reader asks is how a report loses a critical.
48
+ */
49
+ export function toolbar(counts, { dimensions = [] } = {}) {
50
+ const severityFilters = SEVERITY_ORDER.filter((key) => (counts[key] ?? 0) > 0)
51
+ .map(
52
+ (key) =>
53
+ `<button type="button" class="filter sev-${key}" data-filter="severity" data-value="${key}" ` +
54
+ `aria-pressed="true"><span class="dot"></span>${e(SEVERITY[key].label)} ` +
55
+ `<span class="tabular">${e(counts[key] ?? 0)}</span></button>`,
56
+ )
57
+ .join('');
58
+
59
+ const dimensionFilters = dimensions
60
+ .map(
61
+ (dimension) =>
62
+ `<button type="button" class="filter" data-filter="dimension" data-value="${e(dimension.key)}" ` +
63
+ `aria-pressed="true">${e(dimension.label)} <span class="tabular">${e(dimension.count)}</span></button>`,
64
+ )
65
+ .join('');
66
+
67
+ return (
68
+ '<div class="toolbar" role="search">' +
69
+ '<label class="search">' +
70
+ icon('search', 16) +
71
+ '<input type="search" id="finding-search" placeholder="Search findings, APIs, pages, root causes…" ' +
72
+ 'autocomplete="off" aria-label="Search findings">' +
73
+ '</label>' +
74
+ `<div class="filters">${severityFilters}${dimensionFilters}</div>` +
75
+ '<span class="match-count" id="match-count" aria-live="polite"></span>' +
76
+ '<button type="button" class="iconbtn" id="toggle-all" title="Expand or collapse every finding" ' +
77
+ `aria-label="Expand or collapse every finding">${icon('expand', 16)}</button>` +
78
+ '<button type="button" class="iconbtn" id="toggle-theme" title="Switch between light and dark" ' +
79
+ `aria-label="Switch between light and dark">${icon('moon', 16)}</button>` +
80
+ '<button type="button" class="iconbtn" id="print-report" title="Print or save as PDF" ' +
81
+ `aria-label="Print or save as PDF">${icon('print', 16)}</button>` +
82
+ '</div>'
83
+ );
84
+ }
85
+
86
+ /** The lightbox shell. Empty until a screenshot is clicked. */
87
+ export function lightbox() {
88
+ return (
89
+ '<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Screenshot viewer" hidden>' +
90
+ '<div class="lightbox-bar">' +
91
+ '<span class="grow" id="lightbox-name"></span>' +
92
+ '<button type="button" id="lightbox-zoom">Zoom</button>' +
93
+ '<a id="lightbox-open" href="#" target="_blank" rel="noopener noreferrer">Open file</a>' +
94
+ `<button type="button" id="lightbox-close" aria-label="Close viewer">${icon('close', 16)}</button>` +
95
+ '</div>' +
96
+ '<img id="lightbox-img" alt="">' +
97
+ '</div>'
98
+ );
99
+ }