snapdrift 0.9.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-09-14
4
+
5
+ ### Behavior changes
6
+
7
+ - **Local comparisons now always use comparison policy v1.** `generateDriftReport`
8
+ synthesizes `{ "version": 1, "threshold": diff.threshold }` when neither the
9
+ caller nor `diff.comparisonPolicy` supplies a policy, matching the CLI and both
10
+ providers. Dimension mismatches are now `changed[]` signals with union-canvas
11
+ metadata instead of `dimensionChanges[]` (which stays in the summary contract
12
+ but is empty for local comparisons). The strict same-dimension comparator
13
+ remains available to direct `@snapdrift/compare-core` / `comparePngs` callers
14
+ that pass no policy.
15
+
16
+ ### Performance
17
+
18
+ - `compareImages` short-circuits byte-identical buffers and can skip rendering
19
+ the diff PNG via `renderDiffImage: false`; drift reports only render diff
20
+ images for changed routes and resolve/read route PNGs concurrently.
21
+ - Union-canvas size limits are now checked against the PNG IHDR header before
22
+ decoding, so oversized images are rejected before allocating decoded buffers.
23
+
24
+ ### Fixes
25
+
26
+ - `compareImages`, `generateDiffImage`, and `compareWithIgnoreRegions` now reject
27
+ malformed ignore regions and highlight colors instead of silently masking
28
+ nothing or coercing `undefined` channels.
29
+
30
+ ### Features
31
+
32
+ - **Workspace packages** — publish `@snapdrift/compare-core` 1.2.0 and
33
+ `@snapdrift/adapter-fs` 1.3.0 with the new comparison options, validation, and
34
+ performance behavior. The new `renderDiffImage: false` option is surfaced
35
+ through a separate overload, so existing `compareImages` / `comparePngs`
36
+ callers keep a non-optional `diffImageBuffer` with no type or runtime change.
37
+
38
+ ### Documentation
39
+
40
+ - Document the full-page screenshot pixel budget, viewport/device-scale
41
+ estimates, custom-viewport semantics, and remediation for
42
+ `comparison_too_large` failures.
43
+
44
+ ## 0.10.0 - 2026-09-10
45
+
46
+ ### Fixes
47
+
48
+ - **PR diff artifacts remain reviewable from GitHub comments** — generated local
49
+ diff paths are now linked to the authenticated Actions artifact (or workflow
50
+ run fallback) instead of being emitted as broken relative image URLs. Hosted
51
+ legacy captures also retain their reported dimensions and pixel denominator
52
+ when v1 comparison metadata is absent.
53
+
54
+ ### Features
55
+
56
+ - **Unequal-dimension comparisons** — `@snapdrift/compare-core` now exposes
57
+ `compareImages()` for top-left-aligned union-canvas comparisons while the
58
+ existing strict APIs remain unchanged. Updated local and hosted providers
59
+ explicitly request comparison policy v1, record comparison dimensions and the
60
+ geometric union denominator, stage a generated diff PNG, and render it in
61
+ Markdown, HTML, and PR reports. Low-level public adapter calls remain strict
62
+ unless they opt in with `diff.comparisonPolicy`. Legacy dimension shifts remain
63
+ in `dimensionChanges[]`; completed v1 dimension changes are `changed[]` signals
64
+ and do not fail `fail-on-incomplete` alone.
65
+
66
+ - **Workspace packages** — publish `@snapdrift/manifest` 1.4.0,
67
+ `@snapdrift/compare-core` 1.1.0, `@snapdrift/adapter-report-md` 1.2.0, and
68
+ `@snapdrift/adapter-fs` 1.2.0 before activating the updated hosted Snap client.
69
+
3
70
  ## 0.9.0 - 2026-09-09
4
71
 
5
72
  ### Fixes
package/README.md CHANGED
@@ -48,7 +48,7 @@ You keep ownership of checkout, build, startup, readiness, and teardown. SnapDri
48
48
 
49
49
  ```yaml
50
50
  - name: SnapDrift Baseline
51
- uses: ranacseruet/snapdrift@v0.9.0
51
+ uses: ranacseruet/snapdrift@v0.11.0
52
52
  with:
53
53
  mode: baseline
54
54
  repo-config-path: .github/snapdrift.json
@@ -60,7 +60,7 @@ With `provider: "snap"`, baseline publication is a complete, fail-closed snapsho
60
60
 
61
61
  ```yaml
62
62
  - name: SnapDrift Report
63
- uses: ranacseruet/snapdrift@v0.9.0
63
+ uses: ranacseruet/snapdrift@v0.11.0
64
64
  with:
65
65
  mode: pr-diff
66
66
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -107,7 +107,7 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
107
107
  |------|--------------------|
108
108
  | `report-only` | Never |
109
109
  | `fail-on-changes` | Any capture exceeds threshold |
110
- | `fail-on-incomplete` | Captures are missing, dimensions shift, or comparison errors occur |
110
+ | `fail-on-incomplete` | Captures are missing or comparison errors occur |
111
111
  | `strict` | Any drift signal or incomplete comparison appears |
112
112
 
113
113
  ## Current constraints
@@ -115,8 +115,9 @@ Start with `report-only` while baselines settle. Move to `fail-on-changes` or st
115
115
  - Ubuntu runners only (local CLI works on any OS Node 22+ supports)
116
116
  - Full-page capture only
117
117
  - Viewport presets: `desktop` (1440×900) and `mobile` (390×844), or custom `{ "width": number, "height": number }`
118
+ - Full-page comparisons are bounded to a 32 Mi-pixel union canvas; see the [screenshot size budget](docs/contracts.md#screenshot-size-budget) for viewport, device-scale, and remediation guidance
118
119
  - One global `diff.threshold`
119
- - Dimension shifts are reported separately from pixel drift
120
+ - Unequal dimensions compare on a top-left-aligned union canvas via comparison policy v1, which is always applied (synthesized from `diff.threshold` when `diff.comparisonPolicy` is absent)
120
121
  - Local provider writes artifacts to the runner filesystem; for a hosted backend with a dashboard and a shared baseline store, configure `provider: "snap"` (see the [Integration Guide](docs/integration-guide.md#hosted-snap-provider))
121
122
  - Hosted Snap baselines publish only from the default branch and always include the complete configured route set
122
123
 
package/action.yml CHANGED
@@ -155,7 +155,7 @@ runs:
155
155
  - name: SnapDrift baseline
156
156
  id: baseline
157
157
  if: inputs.mode == 'baseline'
158
- uses: ranacseruet/snapdrift/actions/baseline@331e62a7db4b9e743922667adacfe8fd93e51bb2 # v0.9.0
158
+ uses: ranacseruet/snapdrift/actions/baseline@799cfb16ff9d51e57d515cdddd4b329950574ef2 # v0.11.0
159
159
  with:
160
160
  repo-config-path: ${{ inputs.repo-config-path }}
161
161
  route-ids: ${{ inputs.route-ids }}
@@ -165,7 +165,7 @@ runs:
165
165
  - name: SnapDrift pull request diff
166
166
  id: pr-diff
167
167
  if: inputs.mode == 'pr-diff'
168
- uses: ranacseruet/snapdrift/actions/pr-diff@331e62a7db4b9e743922667adacfe8fd93e51bb2 # v0.9.0
168
+ uses: ranacseruet/snapdrift/actions/pr-diff@799cfb16ff9d51e57d515cdddd4b329950574ef2 # v0.11.0
169
169
  with:
170
170
  github-token: ${{ inputs.github-token }}
171
171
  repo-config-path: ${{ inputs.repo-config-path }}
@@ -24,6 +24,10 @@ inputs:
24
24
  description: Artifact label to include in the comment.
25
25
  required: false
26
26
  default: ''
27
+ artifact-url:
28
+ description: Authenticated URL to the uploaded report artifact.
29
+ required: false
30
+ default: ''
27
31
  repo-config-path:
28
32
  description: Path to the SnapDrift config file (needed for provider-aware comment rendering).
29
33
  required: false
@@ -58,6 +62,7 @@ runs:
58
62
  VISUAL_DIFF_SUMMARY_PATH: ${{ inputs.summary-path }}
59
63
  VISUAL_DIFF_RUN_URL: ${{ inputs.run-url }}
60
64
  VISUAL_DIFF_ARTIFACT_NAME: ${{ inputs.artifact-name }}
65
+ VISUAL_DIFF_ARTIFACT_URL: ${{ inputs.artifact-url }}
61
66
  INPUT_PR_NUMBER: ${{ inputs.pr-number }}
62
67
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
63
68
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
@@ -105,6 +110,7 @@ runs:
105
110
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
106
111
  const body = provider.buildCommentBody(summary, {
107
112
  artifactName: process.env.VISUAL_DIFF_ARTIFACT_NAME || '',
113
+ artifactUrl: process.env.VISUAL_DIFF_ARTIFACT_URL || '',
108
114
  runUrl,
109
115
  maxChangedRows,
110
116
  maxErrorRows
@@ -67,6 +67,9 @@ outputs:
67
67
  markdown-path:
68
68
  description: Absolute path to the generated summary markdown file.
69
69
  value: ${{ steps.compare.outputs.markdown_path }}
70
+ diff-images-dir:
71
+ description: Absolute path to generated local diff PNGs.
72
+ value: ${{ steps.compare.outputs.diff_images_dir }}
70
73
  status:
71
74
  description: Computed SnapDrift status.
72
75
  value: ${{ steps.compare.outputs.status }}
@@ -133,6 +136,7 @@ runs:
133
136
  const resolvedOutDir = path.resolve(process.env.OUT_DIR || 'qa-artifacts/snapdrift/drift/current');
134
137
  const resolvedSummaryPath = path.resolve(process.env.SUMMARY_PATH || path.join(resolvedOutDir, 'summary.json'));
135
138
  const resolvedMarkdownPath = path.resolve(process.env.MARKDOWN_PATH || path.join(resolvedOutDir, 'summary.md'));
139
+ const resolvedDiffImagesDir = path.join(resolvedOutDir, 'diffs');
136
140
 
137
141
  await runDriftCheckCli({
138
142
  configPath: process.env.REPO_CONFIG_PATH || undefined,
@@ -143,6 +147,7 @@ runs:
143
147
  baselineRunDir: process.env.BASELINE_RUN_DIR || undefined,
144
148
  currentRunDir: process.env.CURRENT_RUN_DIR || undefined,
145
149
  outDir: resolvedOutDir,
150
+ diffImagesDir: resolvedDiffImagesDir,
146
151
  summaryPath: resolvedSummaryPath,
147
152
  markdownPath: resolvedMarkdownPath,
148
153
  routeIds: splitCommaList(process.env.ROUTE_IDS),
@@ -155,6 +160,7 @@ runs:
155
160
  const outputs = [
156
161
  `summary_path=${resolvedSummaryPath}`,
157
162
  `markdown_path=${resolvedMarkdownPath}`,
163
+ `diff_images_dir=${resolvedDiffImagesDir}`,
158
164
  `status=${summary.status || ''}`,
159
165
  `changed_screenshots=${summary.changedScreenshots || 0}`,
160
166
  `matched_screenshots=${summary.matchedScreenshots || 0}`,
@@ -459,6 +459,7 @@ runs:
459
459
  const resolvedOutDir = path.resolve('qa-artifacts/snapdrift/drift/current');
460
460
  const resolvedSummaryPath = path.join(resolvedOutDir, 'summary.json');
461
461
  const resolvedMarkdownPath = path.join(resolvedOutDir, 'summary.md');
462
+ const resolvedDiffImagesDir = path.join(resolvedOutDir, 'diffs');
462
463
 
463
464
  const captureOptions = {
464
465
  configPath: process.env.REPO_CONFIG_PATH || undefined,
@@ -475,7 +476,8 @@ runs:
475
476
  baselineSourceSha: process.env.BASELINE_SOURCE_SHA || undefined,
476
477
  currentResultsPath: process.env.CURRENT_RESULTS_PATH || undefined,
477
478
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
478
- currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined
479
+ currentRunDir: process.env.CURRENT_SCREENSHOTS_ROOT || undefined,
480
+ diffImagesDir: resolvedDiffImagesDir
479
481
  };
480
482
 
481
483
  const diffed = await diffWithPolicy({
@@ -615,6 +617,7 @@ runs:
615
617
  CURRENT_MANIFEST_PATH: ${{ steps.compare.outputs.current_manifest_file || steps.capture.outputs.manifest_file }}
616
618
  BASELINE_SCREENSHOTS_DIR: ${{ steps.baseline_paths.outputs.screenshots_dir }}
617
619
  CURRENT_SCREENSHOTS_ROOT: ${{ steps.compare.outputs.current_screenshots_root || steps.capture.outputs.screenshots_root }}
620
+ DIFF_IMAGES_DIR: ${{ steps.compare.outputs.diff_images_dir }}
618
621
  run: |
619
622
  node --input-type=module <<'EOF'
620
623
  import fs from 'node:fs/promises';
@@ -635,13 +638,15 @@ runs:
635
638
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
636
639
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
637
640
  baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
638
- currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined
641
+ currentScreenshotsDir: currentScreenshotsRoot ? path.join(currentScreenshotsRoot, 'screenshots') : undefined,
642
+ diffImagesDir: process.env.DIFF_IMAGES_DIR || undefined
639
643
  });
640
644
 
641
645
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
642
646
  EOF
643
647
 
644
- - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
648
+ - id: upload
649
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
645
650
  with:
646
651
  name: ${{ steps.context.outputs.artifact_name }}
647
652
  retention-days: ${{ inputs.artifact-retention-days }}
@@ -663,6 +668,7 @@ runs:
663
668
  PR_NUMBER: ${{ steps.context.outputs.pr_number }}
664
669
  SUMMARY_PATH: ${{ steps.compare.outputs.summary_path || steps.capture.outputs.summary_path || steps.skipped_baseline.outputs.summary_path || steps.skipped_scope.outputs.summary_path }}
665
670
  ARTIFACT_NAME: ${{ steps.context.outputs.artifact_name }}
671
+ ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }}
666
672
  RUN_URL: ${{ steps.context.outputs.run_url }}
667
673
  REPO_CONFIG_PATH: ${{ inputs.repo-config-path }}
668
674
  MAX_CHANGED_ROWS: ${{ inputs.max-changed-rows }}
@@ -727,6 +733,7 @@ runs:
727
733
  const maxErrorRows = Number(process.env.MAX_ERROR_ROWS) || 10;
728
734
  const body = provider.buildCommentBody(summary, {
729
735
  artifactName: process.env.ARTIFACT_NAME || '',
736
+ artifactUrl: process.env.ARTIFACT_URL || '',
730
737
  runUrl: process.env.RUN_URL || '',
731
738
  maxChangedRows,
732
739
  maxErrorRows
@@ -57,6 +57,10 @@ inputs:
57
57
  description: Current screenshots dir for diff artifact staging.
58
58
  required: false
59
59
  default: ''
60
+ diff-images-dir:
61
+ description: Local diff PNG directory for diff artifact staging.
62
+ required: false
63
+ default: ''
60
64
 
61
65
  outputs:
62
66
  bundle-dir:
@@ -91,6 +95,7 @@ runs:
91
95
  CURRENT_MANIFEST_PATH: ${{ inputs.current-manifest-path }}
92
96
  BASELINE_SCREENSHOTS_DIR: ${{ inputs.baseline-screenshots-dir }}
93
97
  CURRENT_SCREENSHOTS_DIR: ${{ inputs.current-screenshots-dir }}
98
+ DIFF_IMAGES_DIR: ${{ inputs.diff-images-dir }}
94
99
  run: |
95
100
  node --input-type=module <<'EOF'
96
101
  import fs from 'node:fs/promises';
@@ -113,7 +118,8 @@ runs:
113
118
  baselineManifestPath: process.env.BASELINE_MANIFEST_PATH || undefined,
114
119
  currentManifestPath: process.env.CURRENT_MANIFEST_PATH || undefined,
115
120
  baselineScreenshotsDir: process.env.BASELINE_SCREENSHOTS_DIR || undefined,
116
- currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined
121
+ currentScreenshotsDir: process.env.CURRENT_SCREENSHOTS_DIR || undefined,
122
+ diffImagesDir: process.env.DIFF_IMAGES_DIR || undefined
117
123
  });
118
124
 
119
125
  await fs.appendFile(process.env.GITHUB_OUTPUT, `bundle_dir=${result.bundleDir}\n`);
package/docs/contracts.md CHANGED
@@ -40,6 +40,7 @@ SnapDrift reads runtime behavior from `.github/snapdrift.json` by default.
40
40
  | `snap.apiKey` | `string` | Inline API key with `${VAR}` interpolation (mutually exclusive with `snap.apiKeyEnv`) |
41
41
  | `snap.projectId` | `string` | Snap project ID or `"auto"` (default: `"auto"`, derives from `GITHUB_REPOSITORY`) |
42
42
  | `snap.onUnavailable` | `string` | Behavior when Snap is unreachable: `"fail"` (default), `"warn-and-skip"`, or `"fallback-local"` |
43
+ | `diff.comparisonPolicy` | `{ "version": 1, "threshold": number }` | Accepted for compatibility; comparison policy v1 is always applied when absent. `threshold` must match `diff.threshold` |
43
44
 
44
45
  When `provider: "snap"` is set, the `snap` block is required. Exactly one of `snap.apiKeyEnv` or `snap.apiKey` must be present. `snap.apiKey` accepts `${VAR}` interpolation (for example `"${SNAP_API_KEY}"`); the referenced environment variable must be set at runtime or the config loader throws.
45
46
 
@@ -154,6 +155,7 @@ The pull request drift bundle contains:
154
155
  | `current/results.json` | Current capture results |
155
156
  | `current/manifest.json` | Current manifest |
156
157
  | `current/screenshots/*.png` | Current images |
158
+ | `diffs/*.png` | Generated v1 diff images for changed screenshots |
157
159
 
158
160
  ## Screenshot manifest shape
159
161
 
@@ -208,6 +210,18 @@ The pull request drift bundle contains:
208
210
  | Field | Type | Description |
209
211
  |:------|:-----|:------------|
210
212
  | `dashboardUrl` | `string?` | Snap dashboard URL for the run (set by `SnapProvider`; omitted by `LocalProvider`) |
213
+ | `comparisonPolicy` | `{ "version": 1, "threshold": number }?` | Effective v1 comparison policy used by the local adapter (synthesized from `diff.threshold` when not configured) |
214
+
215
+ When v1 comparison is applied, each affected `changed[]` item may also contain
216
+ `comparison` with `baseline`, `current`, and `canvas` `{ width, height }`
217
+ objects, `dimensionsChanged`, and the effective `totalPixels` denominator. A
218
+ local changed item with a generated image contains `diffImagePath`, relative to
219
+ the diff output bundle (for example `diffs/home.png`).
220
+
221
+ When a changed item has a generated local `diffImagePath`, its PR comment cell
222
+ links to the uploaded report artifact (or the workflow run when no artifact URL
223
+ was supplied) and shows the bundle-relative path. It does not use that relative
224
+ path as a GitHub-hosted image URL.
211
225
 
212
226
  ### Status values
213
227
 
@@ -215,7 +229,7 @@ The pull request drift bundle contains:
215
229
  |:-------|:--------|
216
230
  | `clean` | All captures matched within threshold |
217
231
  | `changes-detected` | One or more captures exceeded threshold |
218
- | `incomplete` | Missing captures, dimension shifts, or comparison errors occurred |
232
+ | `incomplete` | Missing captures or comparison errors occurred |
219
233
  | `skipped` | The report was intentionally skipped |
220
234
 
221
235
  ### Skipped summary
@@ -237,7 +251,9 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
237
251
  - Mismatch ratio is `different_pixels / total_pixels`
238
252
  - `diff.threshold` applies per screenshot
239
253
  - Missing captures are counted separately from drift signals
240
- - Dimension mismatches skip pixel comparison and land in `dimensionChanges[]`
254
+ - Comparison policy v1 `{ "version": 1, "threshold": number }` is always applied. When `diff.comparisonPolicy` is absent, SnapDrift synthesizes it from `diff.threshold`. Images are top-left aligned on a max-dimension union canvas with no scaling. Overlap pixels are compared, one-sided pixels count as changed (including transparent pixels), and empty union corners do not enter the denominator. Dimension changes land in `changed[]` with comparison metadata and a generated local diff image.
255
+ - `dimensionChanges[]` remains part of the summary contract but is empty for local comparisons in v1. Strict same-dimension comparison (`compareBuffers`) remains available to direct `@snapdrift/compare-core`/`comparePngs` callers that pass no policy.
256
+ - Threshold is applied after pixel aggregation. A mismatch exactly equal to the threshold is matched; a dimension change is still a changed signal independent of ratio.
241
257
  - `diff.mode` controls enforcement, not summary generation
242
258
 
243
259
  ## Drift modes
@@ -246,7 +262,7 @@ Additional missing-baseline fields: `baselineAvailable`, `currentResultsPath`.
246
262
  |:-----|:-------------------|
247
263
  | `report-only` | Never |
248
264
  | `fail-on-changes` | `changedScreenshots > 0` |
249
- | `fail-on-incomplete` | Errors, dimension shifts, or missing captures occur |
265
+ | `fail-on-incomplete` | Errors or missing captures occur; a completed v1 dimension comparison alone does not fail |
250
266
  | `strict` | Any drift or incomplete comparison appears |
251
267
 
252
268
  A summary with `status: "skipped"` — or any summary carrying no `diff.mode` —
@@ -262,6 +278,45 @@ directly, not just for the guarded enforcement step inside `actions/pr-diff`.
262
278
  | `desktop` | 1440 | 900 | 1 | No | No |
263
279
  | `mobile` | 390 | 844 | 3 | Yes | Yes |
264
280
 
281
+ ## Screenshot size budget
282
+
283
+ SnapDrift compares full-page screenshots using their rendered raster dimensions.
284
+ The unequal-dimension comparator bounds the union canvas at `32 × 1024 × 1024`
285
+ pixels (`33,554,432` pixels). A comparison is oversized when:
286
+
287
+ ```text
288
+ max(baselineWidth, currentWidth) × max(baselineHeight, currentHeight) > 33,554,432
289
+ ```
290
+
291
+ This is a raster-pixel limit, not a direct CSS viewport limit. Full-page capture
292
+ height is the document height, and `deviceScaleFactor` increases both raster
293
+ width and raster height. The baseline/current union also means that a dimension
294
+ change can make an otherwise valid image exceed the budget. Leave headroom for
295
+ horizontal overflow or other layout changes that increase the rendered image
296
+ dimensions.
297
+
298
+ The comparator reports this condition with the stable error code
299
+ `comparison_too_large` and includes the baseline, current, and union-canvas
300
+ dimensions in the error message. Approximate maximum full-page CSS heights below
301
+ assume that the raster width remains equal to the configured CSS width and that
302
+ the document height scales by the same device scale factor:
303
+
304
+ | Configuration | CSS viewport | Scale factor | Approx. maximum full-page CSS height |
305
+ |:--------------|:-------------|-------------:|-------------------------------------:|
306
+ | `desktop` preset | 1440 × 900 | 1 | 23,301 px |
307
+ | `mobile` preset | 390 × 844 | 3 | 9,559 px |
308
+ | Custom 390 × 844 viewport | 390 × 844 | 1 | 86,037 px |
309
+
310
+ These are planning estimates, not guarantees. Check the actual `width` and
311
+ `height` recorded in the capture results or screenshot manifest, then apply the
312
+ union-canvas formula above. A custom viewport object currently uses scale factor
313
+ 1 with `isMobile: false` and `hasTouch: false`; it is therefore not equivalent
314
+ to the named `mobile` preset. Use it to avoid unnecessary raster inflation only
315
+ when the route does not require mobile device emulation. Otherwise, reduce the
316
+ captured document or fixture height, or split the coverage across routes when
317
+ that matches the product behavior. Do not resize or crop screenshots, or lower
318
+ `diff.threshold`, to work around this limit.
319
+
265
320
  ## Capture defaults
266
321
 
267
322
  | Setting | Value |
@@ -547,6 +602,7 @@ Snap's server-side notification posting should render the same template from the
547
602
  | Field | Type | Description |
548
603
  |:------|:-----|:------------|
549
604
  | `artifactName` | `string?` | PR diff artifact label |
605
+ | `artifactUrl` | `string?` | Authenticated GitHub Actions artifact URL for diff links |
550
606
  | `runUrl` | `string?` | GitHub Actions run URL (adds `[View run]` link) |
551
607
  | `dashboardUrl` | `string?` | Snap dashboard URL (adds `[View in dashboard →]` link; SnapProvider only) |
552
608
  | `maxChangedRows` | `number` | Max drift-signal rows before truncation (default 20) |
@@ -46,7 +46,7 @@ SnapDrift owns route selection, capture, comparison, skipped-report generation,
46
46
 
47
47
  ```yaml
48
48
  - name: SnapDrift Baseline
49
- uses: ranacseruet/snapdrift@v0.9.0
49
+ uses: ranacseruet/snapdrift@v0.11.0
50
50
  with:
51
51
  mode: baseline
52
52
  repo-config-path: .github/snapdrift.json
@@ -71,7 +71,7 @@ Then add SnapDrift after the app is running:
71
71
 
72
72
  ```yaml
73
73
  - name: SnapDrift Report
74
- uses: ranacseruet/snapdrift@v0.9.0
74
+ uses: ranacseruet/snapdrift@v0.11.0
75
75
  with:
76
76
  mode: pr-diff
77
77
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -86,8 +86,8 @@ The wrappers remain published in their own right, and are equivalent to the disp
86
86
 
87
87
  | Entry point | Equivalent to |
88
88
  |-------------|---------------|
89
- | `ranacseruet/snapdrift@v0.9.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.9.0` |
90
- | `ranacseruet/snapdrift@v0.9.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.9.0` |
89
+ | `ranacseruet/snapdrift@v0.11.0` with `mode: baseline` | `ranacseruet/snapdrift/actions/baseline@v0.11.0` |
90
+ | `ranacseruet/snapdrift@v0.11.0` with `mode: pr-diff` | `ranacseruet/snapdrift/actions/pr-diff@v0.11.0` |
91
91
 
92
92
  Use the wrappers directly when you want to skip the dispatcher's input indirection, and the lower-level `capture`, `compare`, `scope`, `resolve-baseline`, `stage`, `comment`, and `enforce` actions when you need to orchestrate the stages yourself.
93
93
 
@@ -130,7 +130,7 @@ jobs:
130
130
  done
131
131
 
132
132
  - name: SnapDrift Report
133
- uses: ranacseruet/snapdrift@v0.9.0
133
+ uses: ranacseruet/snapdrift@v0.11.0
134
134
  with:
135
135
  mode: pr-diff
136
136
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -170,7 +170,7 @@ jobs:
170
170
  done
171
171
 
172
172
  - name: SnapDrift Report
173
- uses: ranacseruet/snapdrift@v0.9.0
173
+ uses: ranacseruet/snapdrift@v0.11.0
174
174
  with:
175
175
  mode: pr-diff
176
176
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -281,7 +281,8 @@ The `pr-diff` wrapper composes the following low-level steps. They're still avai
281
281
  `error`, while `missing` remains the intentional first-baseline result
282
282
  - `actions/stage` — assemble the baseline or diff bundle for upload
283
283
  - `actions/enforce` — evaluate the summary against `diff.mode` and fail when required
284
- - `actions/comment` — upsert a PR comment from a summary (provider-aware)
284
+ - `actions/comment` — upsert a PR comment from a summary (provider-aware); pass
285
+ `artifact-url` from `upload-artifact` when local diff PNGs are present
285
286
 
286
287
  The two wrapper actions that orchestrate the full pipeline are `actions/baseline` (publish) and `actions/pr-diff` (drift detection). They are the primary integration points.
287
288
 
@@ -309,7 +310,11 @@ See the [Local CLI guide](local-cli.md) for full command reference, flags, direc
309
310
 
310
311
  ## Refresh the baseline automatically
311
312
 
312
- After an intentional layout change or dimension shift merges, the baseline must be republished before SnapDrift can compare like-for-like frames again. Without automation, this is a manual step.
313
+ After an intentional layout change merges, the baseline can be republished as
314
+ usual. Local comparisons always apply comparison policy v1
315
+ (`{ "version": 1, "threshold": number }`) — synthesized from `diff.threshold`
316
+ when not configured — so unequal frames are compared on a union canvas and
317
+ dimension metadata stays in the report.
313
318
 
314
319
  Use the provided workflow template to refresh the baseline automatically on every push to your default branch (i.e. every merge):
315
320
 
@@ -337,11 +342,31 @@ Grant `issues: write` and `pull-requests: write` to the job.
337
342
  The Snap client never retries 4xx and never falls back, even when `onUnavailable` is set. Inspect the response body — the most common cause is a project-id mismatch between `GITHUB_REPOSITORY` (auto-derived) and the project's id on Snap.
338
343
 
339
344
  **Screenshots have different dimensions**
340
- SnapDrift reports this as a dimension shift and skips pixel comparison for that route. Refresh the baseline after the change lands.
345
+ SnapDrift compares the top-left-aligned union canvas, records the route in
346
+ `changed[]`, and writes a local diff PNG. Refresh the baseline after an
347
+ intentional change when you want future runs to return to equal-size
348
+ comparisons.
341
349
 
342
350
  **A route appears in `errors[]`**
343
351
  Capture failed before comparison. Confirm the app is fully ready and reachable before SnapDrift runs.
344
352
 
353
+ **`comparison_too_large` or `union canvas ... exceeds the maximum`**
354
+
355
+ The baseline/current screenshot union is larger than the 33,554,432-pixel
356
+ comparison budget. Because captures are full-page, a short configured viewport
357
+ can still produce an oversized image when the document is tall; the named
358
+ `mobile` preset also renders at device scale factor 3. Inspect the actual image
359
+ dimensions in the capture results or manifest and calculate
360
+ `max(baselineWidth, currentWidth) × max(baselineHeight, currentHeight)`.
361
+
362
+ If the route does not need mobile device emulation, a custom viewport object uses
363
+ scale factor 1 and can avoid unnecessary raster inflation. Otherwise reduce the
364
+ captured content or fixture height, or split the coverage across routes when
365
+ appropriate. Do not resize/crop images or lower `diff.threshold`; neither changes
366
+ the capture budget and either can hide a layout regression. See the [screenshot
367
+ size budget](contracts.md#screenshot-size-budget) for the preset estimates and
368
+ custom-viewport semantics.
369
+
345
370
  **Playwright install runs even though `provider: "snap"`**
346
371
  That's expected for the Snap local-capture hybrid. Playwright runs locally to render the page, then the resulting screenshot is uploaded to Snap. The hybrid kicks in only when `baseUrl` points to a local address; for remote `baseUrl` Snap's render worker captures directly.
347
372
 
package/docs/local-cli.md CHANGED
@@ -225,6 +225,7 @@ After running `capture` and `diff` with default paths:
225
225
  summary.json # structured drift summary
226
226
  summary.md # human-readable markdown report
227
227
  report.html # self-contained HTML report with side-by-side images
228
+ diffs/ # generated diff PNGs for changed routes
228
229
  ```
229
230
 
230
231
  Add `.snapdrift/` to your `.gitignore` to keep local run output out of version control.
@@ -271,7 +272,7 @@ Comparing against baseline ...
271
272
  Report: .snapdrift/diff/report.html
272
273
  ```
273
274
 
274
- **Incomplete run** (errors, missing captures, or dimension shifts):
275
+ **Incomplete run** (errors or missing captures):
275
276
 
276
277
  ```
277
278
  ❌ SnapDrift — incomplete
@@ -280,7 +281,7 @@ Report: .snapdrift/diff/report.html
280
281
  Changed: 0
281
282
  Missing: 1
282
283
  Errors: 1
283
- Dim diff: 1
284
+ Dim diff: 0
284
285
 
285
286
  Report: .snapdrift/diff/report.html
286
287
  ```
@@ -289,13 +290,27 @@ The `Missing`, `Errors`, and `Dim diff` lines only print when their count is gre
289
290
 
290
291
  The `report.html` path is printed whenever the status is anything other than `clean`; pass `--open` to also launch it in your default browser.
291
292
 
293
+ SnapDrift always applies comparison policy v1. It synthesizes the policy from
294
+ `diff.threshold` when `diff` does not declare one, so the explicit opt-in is only
295
+ needed to pin a non-default threshold:
296
+
297
+ ```json
298
+ "comparisonPolicy": { "version": 1, "threshold": 0.01 }
299
+ ```
300
+
301
+ The report includes the baseline, current, and union-canvas dimensions in
302
+ `summary.md` and `report.html`, and stores changed-route diff PNGs under
303
+ `.snapdrift/diff/diffs/`. A completed dimension comparison is a normal changed
304
+ signal, so `fail-on-incomplete` does not fail solely because its dimensions
305
+ differ; `fail-on-changes` and `strict` still enforce it.
306
+
292
307
  ---
293
308
 
294
309
  ## Environment variables
295
310
 
296
311
  | Variable | Applies to | Description |
297
312
  |:---------|:-----------|:------------|
298
- | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route captures per viewport context (positive integer, default `5`). Set to `1` to restore serial behavior for apps with shared session or auth state. |
313
+ | `SNAPDRIFT_CAPTURE_CONCURRENCY` | `capture`, `diff` | Max concurrent route operations (positive integer, default `5`): route captures per viewport context, and per-route resolve/compare during `diff`. Set to `1` to restore serial behavior for apps with shared session or auth state. |
299
314
  | `SNAPDRIFT_CONFIG_PATH` | `capture`, `diff`, `migrate-baselines` | Override the config file path. Equivalent to `--config`. |
300
315
  | `SNAPDRIFT_ROUTE_IDS` | `capture`, `baseline`, `diff` | Comma-separated route ids to scope to. Equivalent to `--routes`; it may not reduce a hosted baseline's complete route set. |
301
316
 
@@ -82,7 +82,7 @@ jobs:
82
82
 
83
83
  - name: Publish SnapDrift baseline
84
84
  if: steps.baseline_candidate.outputs.current == 'true'
85
- uses: ranacseruet/snapdrift@v0.9.0
85
+ uses: ranacseruet/snapdrift@v0.11.0
86
86
  with:
87
87
  mode: baseline
88
88
  repo-config-path: .github/snapdrift.json
package/lib/cli.mjs CHANGED
@@ -430,21 +430,24 @@ export async function runDiffCommand(opts) {
430
430
  const markdownPath = path.join(opts.diffDir, 'summary.md');
431
431
  const htmlPath = path.join(opts.diffDir, 'report.html');
432
432
 
433
+ const diffOptions = /** @type {import('../types/visual-diff-types').ProviderDiffOptions & { diffImagesDir: string }} */ ({
434
+ configPath: opts.configPath,
435
+ routeIds: opts.routes.length > 0 ? opts.routes : undefined,
436
+ baselineResultsPath,
437
+ baselineManifestPath,
438
+ currentResultsPath: currentCapture.resultsPath,
439
+ currentManifestPath: currentCapture.manifestPath,
440
+ baselineRunDir: opts.baselineDir,
441
+ currentRunDir: currentCapture.screenshotsRoot,
442
+ diffImagesDir: path.join(opts.diffDir, 'diffs')
443
+ });
444
+
433
445
  const diffed = await diffWithPolicy({
434
446
  provider: captured.providerName === 'local' ? createProvider('local') : provider,
435
447
  providerName: captured.providerName,
436
448
  localScreenshots: captured.localScreenshots,
437
449
  captureOptions,
438
- diffOptions: {
439
- configPath: opts.configPath,
440
- routeIds: opts.routes.length > 0 ? opts.routes : undefined,
441
- baselineResultsPath,
442
- baselineManifestPath,
443
- currentResultsPath: currentCapture.resultsPath,
444
- currentManifestPath: currentCapture.manifestPath,
445
- baselineRunDir: opts.baselineDir,
446
- currentRunDir: currentCapture.screenshotsRoot
447
- },
450
+ diffOptions,
448
451
  onSkip: () =>
449
452
  process.stdout.write('Visual regression skipped (Snap unavailable, warn-and-skip mode).\n'),
450
453
  onFallback: () => process.stdout.write('Falling back to local provider for the diff...\n'),
@@ -461,7 +464,8 @@ export async function runDiffCommand(opts) {
461
464
 
462
465
  const html = await generateHtmlReport(summary, {
463
466
  baselineRunDir: opts.baselineDir,
464
- currentRunDir
467
+ currentRunDir,
468
+ diffRunDir: opts.diffDir
465
469
  });
466
470
 
467
471
  await Promise.all([
package/lib/provider.mjs CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  import { runBaselineCapture, generateDriftReport, stageArtifacts } from '@snapdrift/adapter-fs';
4
4
  import { buildReportCommentBody } from '@snapdrift/adapter-report-md';
5
- import { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl } from './snap-provider.mjs';
5
+ import {
6
+ SnapProvider,
7
+ SnapApiError,
8
+ SnapUpgradeRequiredError,
9
+ SnapUnavailableError,
10
+ SnapFallbackError,
11
+ SnapSkipError,
12
+ isLocalBaseUrl
13
+ } from './snap-provider.mjs';
6
14
 
7
15
  /** @typedef {import('../types/visual-diff-types').VisualProvider} VisualProvider */
8
16
  /** @typedef {import('../types/visual-diff-types').ProviderCaptureOptions} CaptureOptions */
@@ -55,8 +63,7 @@ export class LocalProvider {
55
63
  */
56
64
  async fetchLatestBaseline(_options) {
57
65
  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.'
66
+ 'LocalProvider.fetchLatestBaseline is not yet implemented. ' + 'Use the pr-diff action for baseline resolution, or wait for a future release.'
60
67
  );
61
68
  }
62
69
 
@@ -113,12 +120,9 @@ const PROVIDER_FACTORIES = {
113
120
  export function createProvider(providerName, config) {
114
121
  const factory = PROVIDER_FACTORIES[providerName];
115
122
  if (!factory) {
116
- throw new Error(
117
- `Unknown SnapDrift provider: "${providerName}". ` +
118
- `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`
119
- );
123
+ throw new Error(`Unknown SnapDrift provider: "${providerName}". ` + `Available providers: ${Object.keys(PROVIDER_FACTORIES).join(', ')}.`);
120
124
  }
121
125
  return factory(config);
122
126
  }
123
127
 
124
- export { SnapProvider, SnapApiError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };
128
+ export { SnapProvider, SnapApiError, SnapUpgradeRequiredError, SnapUnavailableError, SnapFallbackError, SnapSkipError, isLocalBaseUrl };