snapdrift 0.2.1 → 0.4.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.
@@ -1,149 +1,4 @@
1
1
  // @ts-check
2
+ // Re-exports from packages — this module is a thin shim.
2
3
 
3
- export const PR_COMMENT_MARKER = '<!-- snapdrift-report -->';
4
- export const PR_COMMENT_MARKERS = [PR_COMMENT_MARKER];
5
- export const DEFAULT_SNAPDRIFT_REPO_URL = 'https://github.com/ranacseruet/snapdrift';
6
-
7
- /**
8
- * Escape characters that could break markdown table cells or inject links.
9
- * @param {unknown} value
10
- * @returns {string}
11
- */
12
- function escapeMarkdown(value) {
13
- return String(value ?? '')
14
- .replace(/\|/g, '\\|')
15
- .replace(/\[/g, '\\[')
16
- .replace(/\]/g, '\\]');
17
- }
18
-
19
- const STATUS_ICONS = {
20
- clean: '✅',
21
- 'changes-detected': '🟡',
22
- incomplete: '⚠️',
23
- skipped: '⏭️'
24
- };
25
-
26
- const STATUS_LABELS = {
27
- clean: 'Clean',
28
- 'changes-detected': 'Drift detected',
29
- incomplete: 'Incomplete',
30
- skipped: 'Skipped'
31
- };
32
-
33
- /**
34
- * @param {Record<string, unknown>} summary
35
- * @param {{ artifactName?: string, runUrl?: string, maxChangedRows?: number, maxErrorRows?: number }} [meta]
36
- * @returns {string}
37
- */
38
- export function buildReportCommentBody(summary, meta = {}) {
39
- const maxChangedRows = meta.maxChangedRows ?? 20;
40
- const maxErrorRows = meta.maxErrorRows ?? 10;
41
- const status = /** @type {string} */ (summary.status) || 'incomplete';
42
- const statusIcon = STATUS_ICONS[status] || '⚠️';
43
- const statusLabel = STATUS_LABELS[status] || status;
44
- const dimensionChanges = /** @type {Array<Record<string, unknown>>} */ (summary.dimensionChanges) || [];
45
- const errors = /** @type {Array<Record<string, unknown>>} */ (summary.errors) || [];
46
- const errorCount = (/** @type {unknown[]} */ (summary.errors) || []).length;
47
-
48
- const lines = [
49
- PR_COMMENT_MARKER,
50
- `## ${statusIcon} SnapDrift Report — ${statusLabel}`,
51
- '',
52
- '| Signal | Count |',
53
- '|:-------|------:|',
54
- `| Drift signals | ${summary.changedScreenshots || 0} |`,
55
- `| Missing in baseline | ${summary.missingInBaseline || 0} |`,
56
- `| Missing in current capture | ${summary.missingInCurrent || 0} |`,
57
- `| Dimension shifts | ${dimensionChanges.length} |`
58
- ];
59
-
60
- if (summary.message) {
61
- lines.push('');
62
- lines.push(`> **Note:** ${escapeMarkdown(summary.message)}`);
63
- }
64
-
65
- if (errors.length > 0) {
66
- lines.push('');
67
- lines.push('<details><summary>Error details</summary>');
68
- lines.push('');
69
- lines.push('| Route | Viewport | Error |');
70
- lines.push('|:------|:---------|:------|');
71
- for (const item of errors.slice(0, maxErrorRows)) {
72
- lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${escapeMarkdown(item.message)} |`);
73
- }
74
- if (errorCount > maxErrorRows) {
75
- lines.push('');
76
- const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
77
- ? ` — [View full report →](${meta.runUrl})`
78
- : '';
79
- lines.push(`*...and ${errorCount - maxErrorRows} more*${runLinkSuffix}`);
80
- }
81
- lines.push('');
82
- lines.push('</details>');
83
- }
84
-
85
- const changed = /** @type {Array<Record<string, unknown>>} */ (summary.changed) || [];
86
- if (changed.length > 0) {
87
- lines.push('');
88
- lines.push('<details><summary>Drift signals</summary>');
89
- lines.push('');
90
- lines.push('| Route | Viewport | Mismatch |');
91
- lines.push('|:------|:---------|:---------|');
92
- for (const item of changed.slice(0, maxChangedRows)) {
93
- const percentChanged = typeof item.mismatchRatio === 'number'
94
- ? `${(item.mismatchRatio * 100).toFixed(2)}%`
95
- : 'n/a';
96
- lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${percentChanged} |`);
97
- }
98
- if (changed.length > maxChangedRows) {
99
- lines.push('');
100
- const runLinkSuffix = meta.runUrl && /^https?:\/\//.test(meta.runUrl)
101
- ? ` — [View full report →](${meta.runUrl})`
102
- : '';
103
- lines.push(`*...and ${changed.length - maxChangedRows} more*${runLinkSuffix}`);
104
- }
105
- lines.push('');
106
- lines.push('</details>');
107
- }
108
-
109
- if (dimensionChanges.length > 0) {
110
- lines.push('');
111
- lines.push('<details open><summary>Dimension shifts — comparison skipped</summary>');
112
- lines.push('');
113
- lines.push('> SnapDrift detected a dimension shift between the baseline and current capture. Pixel comparison was skipped for these routes.');
114
- lines.push('>');
115
- lines.push('> **Next step:** refresh the baseline after this change lands so SnapDrift can compare like-for-like frames.');
116
- lines.push('');
117
- lines.push('| Route | Viewport | Baseline | Current |');
118
- lines.push('|:------|:---------|:---------|:--------|');
119
- for (const item of dimensionChanges) {
120
- lines.push(`| ${escapeMarkdown(item.id)} | ${escapeMarkdown(item.viewport)} | ${item.baselineWidth}×${item.baselineHeight} | ${item.currentWidth}×${item.currentHeight} |`);
121
- }
122
- lines.push('');
123
- lines.push('</details>');
124
- }
125
-
126
- const metaItems = [];
127
- if (meta.artifactName) {
128
- metaItems.push(`artifact \`${meta.artifactName}\``);
129
- }
130
- if (summary.baselineArtifactName) {
131
- metaItems.push(`baseline \`${summary.baselineArtifactName}\``);
132
- }
133
- if (summary.baselineSourceSha) {
134
- const sha = /** @type {string} */ (summary.baselineSourceSha);
135
- metaItems.push(`sha \`${sha.slice(0, 7)}\``);
136
- }
137
- if (meta.runUrl && /^https?:\/\//.test(meta.runUrl)) {
138
- metaItems.push(`[View run](${meta.runUrl})`);
139
- }
140
- if (metaItems.length > 0) {
141
- lines.push('');
142
- lines.push(`<sub>SnapDrift · ${metaItems.join(' · ')}</sub>`);
143
- }
144
-
145
- lines.push('');
146
- lines.push(`<div align="right"><sub>Powered by <a href="${DEFAULT_SNAPDRIFT_REPO_URL}">SnapDrift</a></sub></div>`);
147
-
148
- return lines.join('\n');
149
- }
4
+ export { buildReportCommentBody, PR_COMMENT_MARKER, PR_COMMENT_MARKERS, escapeMarkdown } from '@snapdrift/adapter-report-md';
@@ -0,0 +1,106 @@
1
+ // @ts-check
2
+
3
+ import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
4
+ import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
5
+ import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError } from './snap-provider.mjs';
6
+
7
+ /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
8
+ /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
9
+ /** @typedef {import('../types/visual-diff-types').ProviderCaptureResult} CaptureResult */
10
+ /** @typedef {import('../types/visual-diff-types').ProviderDiffOptions} DiffOptions */
11
+ /** @typedef {import('../types/visual-diff-types').ProviderDiffResult} DiffResult */
12
+ /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineOptions} PublishBaselineOptions */
13
+ /** @typedef {import('../types/visual-diff-types').ProviderPublishBaselineResult} PublishBaselineResult */
14
+ /** @typedef {import('../types/visual-diff-types').ProviderFetchBaselineOptions} FetchBaselineOptions */
15
+ /** @typedef {import('../types/visual-diff-types').ProviderBaselineData} BaselineData */
16
+ /** @typedef {import('../types/visual-diff-types').SnapConfig} SnapConfig */
17
+ /** @typedef {import('../types/visual-diff-types').VisualRegressionConfig} VisualRegressionConfig */
18
+
19
+ /**
20
+ * Local filesystem provider — wraps the current snapdrift behavior
21
+ * (Playwright capture, pixel diff, GH artifact baselines).
22
+ * @implements {VisualProvider}
23
+ */
24
+ export class LocalProvider {
25
+ /**
26
+ * @param {CaptureOptions} options
27
+ * @returns {Promise<CaptureResult>}
28
+ */
29
+ async capture(options) {
30
+ return runBaselineCapture(options);
31
+ }
32
+
33
+ /**
34
+ * @param {DiffOptions} options
35
+ * @returns {Promise<DiffResult>}
36
+ */
37
+ async diff(options) {
38
+ return generateDriftReport(options);
39
+ }
40
+
41
+ /**
42
+ * @param {PublishBaselineOptions} options
43
+ * @returns {Promise<PublishBaselineResult>}
44
+ */
45
+ async publishBaseline(options) {
46
+ const { configPath: _unused, ...stageOpts } = options;
47
+ return stageArtifacts({ artifactType: 'baseline', ...stageOpts });
48
+ }
49
+
50
+ /**
51
+ * @param {FetchBaselineOptions} _options
52
+ * @returns {Promise<never>}
53
+ * @throws {Error} Not yet implemented for LocalProvider.
54
+ * Use the pr-diff action for baseline resolution, or wait for a future release.
55
+ */
56
+ async fetchLatestBaseline(_options) {
57
+ throw new Error(
58
+ 'LocalProvider.fetchLatestBaseline is not yet implemented. ' +
59
+ 'Use the pr-diff action for baseline resolution, or wait for a future release.'
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Build a PR comment body from a diff summary.
65
+ * LocalProvider omits the dashboard link (no dashboard exists).
66
+ *
67
+ * @param {Record<string, unknown>} summary
68
+ * @param {import('../types/visual-diff-types').ProviderCommentMeta} [meta]
69
+ * @returns {string}
70
+ */
71
+ buildCommentBody(summary, meta = {}) {
72
+ return buildReportCommentBody(summary, meta);
73
+ }
74
+ }
75
+
76
+ /** @type {Record<string, (config?: VisualRegressionConfig) => VisualProvider>} */
77
+ const PROVIDER_FACTORIES = {
78
+ local: () => new LocalProvider(),
79
+ snap: (config) => {
80
+ if (!config?.snap) {
81
+ throw new Error('Snap provider requires snap configuration.');
82
+ }
83
+ return new SnapProvider(config.snap);
84
+ }
85
+ };
86
+
87
+ /**
88
+ * Create a VisualProvider instance by name.
89
+ *
90
+ * @param {string} providerName - Provider identifier ("local" or "snap")
91
+ * @param {VisualRegressionConfig} [config] - Full config (required for "snap" provider)
92
+ * @returns {VisualProvider}
93
+ * @throws {Error} If the provider name is not recognized
94
+ */
95
+ export function createProvider(providerName, config) {
96
+ const factory = PROVIDER_FACTORIES[providerName];
97
+ if (!factory) {
98
+ throw new Error(
99
+ `Unknown SnapDrift provider: "${providerName}". ` +
100
+ `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`
101
+ );
102
+ }
103
+ return factory(config);
104
+ }
105
+
106
+ export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError };
package/lib/report.mjs CHANGED
@@ -1,40 +1,20 @@
1
1
  // @ts-check
2
+ // Shim: delegates to adapter-report-md with default filesystem imageReader.
2
3
 
3
4
  import fs from 'node:fs/promises';
4
-
5
- import { resolveImagePath } from './compare-results.mjs';
6
-
7
- /** @typedef {import('../types/visual-diff-types').VisualDiffSummary} DriftSummary */
8
- /** @typedef {import('../types/visual-diff-types').VisualViewport} VisualViewport */
9
-
10
- /**
11
- * @param {VisualViewport | undefined} viewport
12
- * @returns {string}
13
- */
14
- function formatViewport(viewport) {
15
- if (!viewport) return '';
16
- return typeof viewport === 'string' ? viewport : `${viewport.width}x${viewport.height}`;
17
- }
18
-
19
- /**
20
- * @param {string} text
21
- * @returns {string}
22
- */
23
- function escapeHtml(text) {
24
- return String(text)
25
- .replace(/&/g, '&amp;')
26
- .replace(/</g, '&lt;')
27
- .replace(/>/g, '&gt;')
28
- .replace(/"/g, '&quot;');
29
- }
5
+ import { resolveImagePath } from '@snapdrift/adapter-fs';
6
+ import { generateHtmlReport as _generateHtmlReport } from '@snapdrift/adapter-report-md';
30
7
 
31
8
  /**
32
- * @param {string} filePath
9
+ * Default filesystem image reader — resolves image path and reads as base64.
10
+ * @param {string} runDir
11
+ * @param {string} imagePath
33
12
  * @returns {Promise<string | null>}
34
13
  */
35
- async function readFileAsBase64(filePath) {
14
+ async function defaultImageReader(runDir, imagePath) {
36
15
  try {
37
- const buffer = await fs.readFile(filePath);
16
+ const resolved = await resolveImagePath(runDir, imagePath);
17
+ const buffer = await fs.readFile(resolved);
38
18
  return buffer.toString('base64');
39
19
  } catch {
40
20
  return null;
@@ -42,227 +22,18 @@ async function readFileAsBase64(filePath) {
42
22
  }
43
23
 
44
24
  /**
45
- * @param {string | null} base64Data
46
- * @param {string} alt
47
- * @returns {string}
48
- */
49
- function imgTag(base64Data, alt) {
50
- if (!base64Data) return `<span class="no-img">Image unavailable</span>`;
51
- return `<img src="data:image/png;base64,${base64Data}" alt="${escapeHtml(alt)}" />`;
52
- }
53
-
54
- /**
55
- * Generates a self-contained HTML report from a diff summary.
56
- * Images are embedded as base64 data URIs when screenshot directories are provided.
57
- *
58
- * @param {DriftSummary} summary
25
+ * @param {import('../types/visual-diff-types').VisualDiffSummary} summary
59
26
  * @param {{
60
27
  * baselineRunDir?: string,
61
- * currentRunDir?: string
28
+ * currentRunDir?: string,
29
+ * imageReader?: (runDir: string, imagePath: string) => Promise<string | null>
62
30
  * }} [options]
63
31
  * @returns {Promise<string>}
64
32
  */
65
33
  export async function generateHtmlReport(summary, options = {}) {
66
- const { baselineRunDir, currentRunDir } = options;
67
- const dimensionChanges = summary.dimensionChanges || [];
68
-
69
- const statusClass = {
70
- clean: 'status-clean',
71
- 'changes-detected': 'status-changes',
72
- incomplete: 'status-incomplete',
73
- skipped: 'status-skipped'
74
- }[summary.status || 'incomplete'] || 'status-incomplete';
75
-
76
- const statusLabel = {
77
- clean: '&#10003; Clean',
78
- 'changes-detected': 'Drift detected',
79
- incomplete: 'Incomplete',
80
- skipped: 'Skipped'
81
- }[summary.status || 'incomplete'] || 'Incomplete';
82
-
83
- // --- Changed routes ---
84
- let changedHtml;
85
- if (summary.changed.length === 0) {
86
- changedHtml = '<p class="none">None</p>';
87
- } else {
88
- const rows = await Promise.all(summary.changed.map(async (item) => {
89
- let baselineImgHtml = '';
90
- let currentImgHtml = '';
91
-
92
- if (baselineRunDir) {
93
- try {
94
- const resolved = await resolveImagePath(baselineRunDir, item.baselineImagePath);
95
- baselineImgHtml = imgTag(await readFileAsBase64(resolved), `Baseline: ${item.id}`);
96
- } catch {
97
- baselineImgHtml = imgTag(null, `Baseline: ${item.id}`);
98
- }
99
- }
100
- if (currentRunDir) {
101
- try {
102
- const resolved = await resolveImagePath(currentRunDir, item.currentImagePath);
103
- currentImgHtml = imgTag(await readFileAsBase64(resolved), `Current: ${item.id}`);
104
- } catch {
105
- currentImgHtml = imgTag(null, `Current: ${item.id}`);
106
- }
107
- }
108
-
109
- const dataRow = `<tr>
110
- <td>${escapeHtml(item.id)}</td>
111
- <td>${escapeHtml(item.path)}</td>
112
- <td>${escapeHtml(formatViewport(item.viewport))}</td>
113
- <td>${(item.mismatchRatio * 100).toFixed(2)}%</td>
114
- <td>${item.differentPixels}/${item.totalPixels}</td>
115
- </tr>`;
116
-
117
- const imagesRow = (baselineImgHtml || currentImgHtml) ? `<tr class="images-row"><td colspan="5">
118
- <div class="image-compare">
119
- <div class="image-col"><div class="image-label">Baseline</div>${baselineImgHtml || '<span class="no-img">&ndash;</span>'}</div>
120
- <div class="image-col"><div class="image-label">Current</div>${currentImgHtml || '<span class="no-img">&ndash;</span>'}</div>
121
- </div>
122
- </td></tr>` : '';
123
-
124
- return dataRow + imagesRow;
125
- }));
126
-
127
- changedHtml = `<table>
128
- <thead><tr><th>Route</th><th>Path</th><th>Viewport</th><th>Mismatch</th><th>Pixels changed</th></tr></thead>
129
- <tbody>${rows.join('')}</tbody>
130
- </table>`;
131
- }
132
-
133
- // --- Capture gaps ---
134
- let missingHtml;
135
- if (summary.missing.length === 0) {
136
- missingHtml = '<p class="none">None</p>';
137
- } else {
138
- const rows = summary.missing.map((item) => `<tr>
139
- <td>${escapeHtml(item.id)}</td>
140
- <td>${escapeHtml(item.location)}</td>
141
- <td>${escapeHtml(item.reason)}</td>
142
- </tr>`).join('');
143
- missingHtml = `<table>
144
- <thead><tr><th>Route</th><th>Missing from</th><th>Reason</th></tr></thead>
145
- <tbody>${rows}</tbody>
146
- </table>`;
147
- }
148
-
149
- // --- Dimension shifts ---
150
- let dimensionHtml;
151
- if (dimensionChanges.length === 0) {
152
- dimensionHtml = '<p class="none">None</p>';
153
- } else {
154
- const rows = dimensionChanges.map((item) => `<tr>
155
- <td>${escapeHtml(item.id)}</td>
156
- <td>${escapeHtml(formatViewport(item.viewport))}</td>
157
- <td>${item.baselineWidth}&times;${item.baselineHeight}</td>
158
- <td>${item.currentWidth}&times;${item.currentHeight}</td>
159
- </tr>`).join('');
160
- dimensionHtml = `<table>
161
- <thead><tr><th>Route</th><th>Viewport</th><th>Baseline</th><th>Current</th></tr></thead>
162
- <tbody>${rows}</tbody>
163
- </table>`;
164
- }
165
-
166
- // --- Errors ---
167
- let errorsHtml;
168
- if (summary.errors.length === 0) {
169
- errorsHtml = '<p class="none">None</p>';
170
- } else {
171
- const rows = summary.errors.map((item) => `<tr>
172
- <td>${escapeHtml(item.id)}</td>
173
- <td>${escapeHtml(item.message)}</td>
174
- </tr>`).join('');
175
- errorsHtml = `<table>
176
- <thead><tr><th>Route</th><th>Error</th></tr></thead>
177
- <tbody>${rows}</tbody>
178
- </table>`;
34
+ const enrichedOptions = { ...options };
35
+ if (!enrichedOptions.imageReader && (options.baselineRunDir || options.currentRunDir)) {
36
+ enrichedOptions.imageReader = defaultImageReader;
179
37
  }
180
-
181
- // --- Meta bar ---
182
- const metaParts = [];
183
- if (summary.baselineArtifactName) metaParts.push(`Baseline: <code>${escapeHtml(summary.baselineArtifactName)}</code>`);
184
- if (summary.baselineSourceSha) metaParts.push(`SHA: <code>${escapeHtml(summary.baselineSourceSha)}</code>`);
185
- metaParts.push(`Diff mode: <code>${escapeHtml(summary.diffMode)}</code>`);
186
- metaParts.push(`Threshold: <code>${summary.threshold}</code>`);
187
-
188
- const generatedAt = escapeHtml(summary.finishedAt || summary.startedAt);
189
-
190
- return `<!DOCTYPE html>
191
- <html lang="en">
192
- <head>
193
- <meta charset="UTF-8" />
194
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
195
- <title>SnapDrift Report</title>
196
- <style>
197
- *, *::before, *::after { box-sizing: border-box; }
198
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 24px; background: #f6f8fa; color: #24292f; line-height: 1.5; }
199
- a { color: #0969da; }
200
- h1 { margin: 0 0 8px; font-size: 1.4em; }
201
- h2 { font-size: 1.1em; border-bottom: 1px solid #d0d7de; padding-bottom: 6px; margin: 0 0 12px; }
202
- .header { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 20px 24px; margin-bottom: 20px; }
203
- .status { display: inline-block; font-size: 1.1em; font-weight: 600; padding: 3px 10px; border-radius: 12px; }
204
- .status-clean { background: #dafbe1; color: #116329; }
205
- .status-changes { background: #fff8c5; color: #7d4e00; }
206
- .status-incomplete { background: #fff3cd; color: #664d03; }
207
- .status-skipped { background: #f6f8fa; color: #57606a; }
208
- .stats { display: flex; gap: 10px; margin-top: 14px; flex-wrap: wrap; }
209
- .stat { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 6px; padding: 8px 14px; min-width: 100px; }
210
- .stat strong { display: block; font-size: 1.5em; line-height: 1.2; }
211
- .stat span { font-size: 0.8em; color: #57606a; }
212
- .meta { margin-top: 12px; font-size: 0.85em; color: #57606a; }
213
- .meta em { margin-right: 14px; font-style: normal; }
214
- section { background: #fff; border: 1px solid #d0d7de; border-radius: 8px; padding: 20px 24px; margin-bottom: 20px; }
215
- table { border-collapse: collapse; width: 100%; font-size: 0.9em; }
216
- th { background: #f6f8fa; text-align: left; padding: 7px 10px; border-bottom: 1px solid #d0d7de; white-space: nowrap; }
217
- td { padding: 7px 10px; border-bottom: 1px solid #eaecef; vertical-align: top; word-break: break-word; }
218
- tr:last-child td { border-bottom: none; }
219
- .images-row td { padding: 0 10px 14px; background: #fafafa; }
220
- .image-compare { display: flex; gap: 16px; flex-wrap: wrap; }
221
- .image-col { flex: 1; min-width: 260px; }
222
- .image-label { font-size: 0.78em; font-weight: 600; color: #57606a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
223
- .image-col img { max-width: 100%; border: 1px solid #d0d7de; border-radius: 4px; display: block; }
224
- .no-img { color: #57606a; font-size: 0.85em; font-style: italic; }
225
- .none { color: #57606a; font-style: italic; margin: 0; }
226
- code { background: #eaeef2; padding: 1px 5px; border-radius: 4px; font-size: 0.88em; }
227
- footer { font-size: 0.8em; color: #57606a; text-align: right; margin-top: 8px; }
228
- </style>
229
- </head>
230
- <body>
231
- <div class="header">
232
- <h1>SnapDrift Visual Diff Report</h1>
233
- <span class="status ${statusClass}">${statusLabel}</span>
234
- <div class="stats">
235
- <div class="stat"><strong>${summary.changedScreenshots}</strong><span>Drift signals</span></div>
236
- <div class="stat"><strong>${summary.matchedScreenshots}</strong><span>Stable captures</span></div>
237
- <div class="stat"><strong>${summary.missingInBaseline + summary.missingInCurrent}</strong><span>Capture gaps</span></div>
238
- <div class="stat"><strong>${dimensionChanges.length}</strong><span>Dimension shifts</span></div>
239
- <div class="stat"><strong>${summary.errors.length}</strong><span>Errors</span></div>
240
- </div>
241
- <div class="meta">${metaParts.map((p) => `<em>${p}</em>`).join('')}</div>
242
- </div>
243
-
244
- <section>
245
- <h2>Drift signals</h2>
246
- ${changedHtml}
247
- </section>
248
-
249
- <section>
250
- <h2>Capture gaps</h2>
251
- ${missingHtml}
252
- </section>
253
-
254
- <section>
255
- <h2>Dimension shifts</h2>
256
- ${dimensionHtml}
257
- </section>
258
-
259
- <section>
260
- <h2>Comparison errors</h2>
261
- ${errorsHtml}
262
- </section>
263
-
264
- <footer>Generated by <a href="https://github.com/ranacseruet/snapdrift">SnapDrift</a> &middot; ${generatedAt}</footer>
265
- </body>
266
- </html>
267
- `;
268
- }
38
+ return _generateHtmlReport(summary, enrichedOptions);
39
+ }