Skip to content

Commit 4bd6eed

Browse files
authored
Add snapshot canvas theme sidecar support (#273)
Adds support for a new metadata property, `canvas_theme` that can be associated with a snapshot image. When set, the value is respected by the Sentry web UI and renders the image background according to the `light` or `dark` value specified. Expose a view modifier and preference that allows clients to control this value, analogous to the way other image metadata is specified. Refs: EME-1225
1 parent 74b9ab9 commit 4bd6eed

14 files changed

Lines changed: 92 additions & 18 deletions

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Each `.json` sidecar contains the following fields:
152152
| `group` | Grouping key Sentry uses to organize related snapshots. Generated from the preview name, file path, and module by default. |
153153
| `diff_threshold` | Allowed visual difference for this snapshot. See details below. |
154154
| `tags` | Optional key-value pairs used to filter and group snapshots in Sentry. |
155+
| `canvas_theme` | Optional `"light"` or `"dark"` value that controls the background canvas used to display the snapshot image in Sentry's web UI. |
155156
| `context` | Supporting metadata such as test name, simulator info, orientation, color scheme, source line, preview attributes, and any custom context you add. These fields are surfaced on the snapshot detail page in Sentry's UI. |
156157

157158
SnapshotPreviews adds these `context` keys by default when values are available: `test_name`, `accessibility_enabled`, `simulator.device_name`, `simulator.model_identifier`, `preview.index`, `preview.display_name`, `preview.container_type_name`, `preview.container_display_name`, `preview.preferred_color_scheme`, `preview.orientation`, and `preview.line`.
@@ -162,6 +163,8 @@ Use `.snapshotGroup(...)` to override the default `group` behaviour in the Sentr
162163

163164
Use the `.snapshotDiffThreshold(...)` view modifier from the `SnapshotPreferences` product to customize the allowed visual difference for a specific preview. For example, `.snapshotDiffThreshold(0.05)` allows up to a 5% difference for that snapshot.
164165

166+
Use `.snapshotCanvasTheme(...)` to set the light or dark canvas used behind the image in Sentry's web UI. This is display metadata only; it does not change the rendered snapshot image.
167+
165168
```swift
166169
import SnapshotPreferences
167170
@@ -171,6 +174,7 @@ import SnapshotPreferences
171174
.snapshotAdditionalContext(["fixture": "city-route"])
172175
.snapshotGroup("Navigation")
173176
.snapshotDiffThreshold(0.05)
177+
.snapshotCanvasTheme(.dark)
174178
}
175179
```
176180

@@ -223,6 +227,7 @@ Link the `SnapshotPreferences` product to your app target to customize individua
223227
| `.snapshotAdditionalContext(["fixture": "loaded"])` | You want extra sidecar metadata for debugging or filtering. | Adds fields to the JSON sidecar `context` object. Repeated context modifiers merge, and later duplicate keys win. Custom keys override generated context keys. |
224228
| `.snapshotGroup("Checkout")` | You want to control how related snapshots are grouped in Sentry. | Overrides the top-level `group` in the JSON sidecar. Also accepts `.snapshotGroup(.module)` to group by the preview container's module name, and `.snapshotGroup(.default)` to keep the generated group. Empty or whitespace-only custom strings fall back to the generated group. Does not change exported filenames or all-image-names manifest output. |
225229
| `.snapshotDiffThreshold(0.05)` | A snapshot has small expected pixel differences. | Sets `diff_threshold` in the exported sidecar for this preview. `0.05` allows up to a 5% changed-pixel share before Sentry marks the image as changed. |
230+
| `.snapshotCanvasTheme(.dark)` | A transparent or framed image should be shown on a specific light/dark canvas in Sentry. | Sets top-level `canvas_theme` to `"light"` or `"dark"` in the JSON sidecar. Does not change the rendered snapshot image. |
226231

227232
### Variants
228233

‎Sources/SnapshotPreferences/EmergeModifierFinder.swift‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class EmergeModifierState: NSObject {
2828
tags = [:]
2929
additionalContext = [:]
3030
groupOverride = nil
31+
canvasTheme = nil
3132
}
3233

3334
var expansionPreference: Bool?
@@ -38,6 +39,7 @@ class EmergeModifierState: NSObject {
3839
var tags: [String: String] = [:]
3940
var additionalContext: [String: SnapshotMetadataValue] = [:]
4041
var groupOverride: SnapshotGroup?
42+
var canvasTheme: SnapshotCanvasTheme?
4143
}
4244

4345
@objc(EmergeModifierFinder)
@@ -69,5 +71,8 @@ class EmergeModifierFinder: NSObject {
6971
.onPreferenceChange(SnapshotGroupPreferenceKey.self, perform: { value in
7072
EmergeModifierState.shared.groupOverride = value
7173
})
74+
.onPreferenceChange(SnapshotCanvasThemePreferenceKey.self, perform: { value in
75+
EmergeModifierState.shared.canvasTheme = value
76+
})
7277
}
7378
}

‎Sources/SnapshotPreferences/SnapshotMetadataPreference.swift‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ struct SnapshotAdditionalContextPreferenceKey: PreferenceKey {
3333
}
3434
}
3535

36+
struct SnapshotCanvasThemePreferenceKey: PreferenceKey {
37+
static var defaultValue: SnapshotCanvasTheme? = nil
38+
39+
static func reduce(value: inout SnapshotCanvasTheme?, nextValue: () -> SnapshotCanvasTheme?) {
40+
value = nextValue() ?? value
41+
}
42+
}
43+
3644
extension View {
3745
/// Adds tags to the exported snapshot sidecar.
3846
///
@@ -71,4 +79,12 @@ extension View {
7179
public func snapshotGroup(_ group: SnapshotGroup) -> some View {
7280
preference(key: SnapshotGroupPreferenceKey.self, value: group)
7381
}
82+
83+
/// Sets the top-level `canvas_theme` field in the exported snapshot sidecar.
84+
///
85+
/// This controls the light or dark canvas used when displaying the snapshot image in
86+
/// Sentry's web UI. It does not change the rendered snapshot image itself.
87+
public func snapshotCanvasTheme(_ canvasTheme: SnapshotCanvasTheme) -> some View {
88+
preference(key: SnapshotCanvasThemePreferenceKey.self, value: canvasTheme)
89+
}
7490
}

‎Sources/SnapshotPreviewsCore/AppKitRenderingStrategy.swift‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public class AppKitRenderingStrategy: RenderingStrategy {
4747
window.contentViewController = NSViewController()
4848
window.setContentSize(AppKitContainer.defaultSize)
4949
window.contentViewController = vc
50-
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride in
50+
vc.rendered = { [weak window, weak vc] mode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride, canvasTheme in
5151
DispatchQueue.main.async {
5252
Self.takeSnapshot(mode: mode ?? .nsView, viewController: vc, window: window) { image in
5353
completion(
@@ -59,7 +59,8 @@ public class AppKitRenderingStrategy: RenderingStrategy {
5959
appStoreSnapshot: appStoreSnapshot,
6060
tags: tags,
6161
additionalContext: additionalContext,
62-
groupOverride: groupOverride))
62+
groupOverride: groupOverride,
63+
canvasTheme: canvasTheme))
6364
}
6465
}
6566
}
@@ -119,7 +120,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
119120
var heightAnchor: NSLayoutConstraint?
120121
var previousHeight: CGFloat?
121122

122-
public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?) -> Void)? {
123+
public var rendered: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?, SnapshotCanvasTheme?) -> Void)? {
123124
didSet { didCall = false }
124125
}
125126

@@ -169,7 +170,7 @@ final class AppKitContainer: NSHostingController<EmergeModifierView>, ScrollExpa
169170
guard !didCall else { return }
170171

171172
didCall = true
172-
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride)
173+
rendered?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride, rootView.canvasTheme)
173174
}
174175

175176
override func updateViewConstraints() {

‎Sources/SnapshotPreviewsCore/ExpandingViewController.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
3131
private var startTime: UInt64?
3232
private var timer: Timer?
3333

34-
public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?, Error?) -> Void)? {
34+
public var expansionSettled: ((EmergeRenderingMode?, Float?, Bool?, Bool?, [String: String], [String: SnapshotMetadataValue], SnapshotGroup?, SnapshotCanvasTheme?, Error?) -> Void)? {
3535
didSet { didCall = false }
3636
}
3737

@@ -78,7 +78,7 @@ public final class ExpandingViewController: UIHostingController<EmergeModifierVi
7878
guard !didCall else { return }
7979

8080
didCall = true
81-
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride, error)
81+
expansionSettled?(rootView.emergeRenderingMode, rootView.precision, rootView.accessibilityEnabled, rootView.appStoreSnapshot, rootView.tags, rootView.additionalContext, rootView.groupOverride, rootView.canvasTheme, error)
8282
stopAndResetTimer()
8383
}
8484

‎Sources/SnapshotPreviewsCore/ModifierFinder.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ public struct EmergeModifierView: View {
5959
stateMirror?.descendant("groupOverride") as? SnapshotGroup
6060
}
6161

62+
var canvasTheme: SnapshotCanvasTheme? {
63+
stateMirror?.descendant("canvasTheme") as? SnapshotCanvasTheme
64+
}
65+
6266
var supportsExpansion: Bool {
6367
stateMirror?.descendant("expansionPreference") as? Bool ?? true
6468
}

‎Sources/SnapshotPreviewsCore/RenderingStrategy.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public struct SnapshotResult {
3333
appStoreSnapshot: Bool?,
3434
tags: [String: String] = [:],
3535
additionalContext: [String: SnapshotMetadataValue] = [:],
36-
groupOverride: SnapshotGroup? = nil)
36+
groupOverride: SnapshotGroup? = nil,
37+
canvasTheme: SnapshotCanvasTheme? = nil)
3738
{
3839
self.image = image
3940
self.precision = precision
@@ -43,6 +44,7 @@ public struct SnapshotResult {
4344
self.tags = tags
4445
self.additionalContext = additionalContext
4546
self.groupOverride = groupOverride
47+
self.canvasTheme = canvasTheme
4648
}
4749

4850
public let image: Result<ImageType, Error>
@@ -53,6 +55,7 @@ public struct SnapshotResult {
5355
public let tags: [String: String]
5456
public let additionalContext: [String: SnapshotMetadataValue]
5557
public let groupOverride: SnapshotGroup?
58+
public let canvasTheme: SnapshotCanvasTheme?
5659
}
5760

5861
public protocol RenderingStrategy {
@@ -68,4 +71,3 @@ extension RenderingStrategy {
6871
testHandler?.perform(NSSelectorFromString("setup"))
6972
}
7073
}
71-

‎Sources/SnapshotPreviewsCore/SwiftUIRenderingStrategy.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@ public class SwiftUIRenderingStrategy: RenderingStrategy {
3535
let image = renderer.nsImage
3636
#endif
3737
if let image {
38-
completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride))
38+
completion(SnapshotResult(image: .success(image), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride, canvasTheme: wrappedView.canvasTheme))
3939
} else {
40-
completion(SnapshotResult(image: .failure(RenderingError.failedRendering(.zero)), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride))
40+
completion(SnapshotResult(image: .failure(RenderingError.failedRendering(.zero)), precision: wrappedView.precision, accessibilityEnabled: wrappedView.accessibilityEnabled, colorScheme: colorScheme, appStoreSnapshot: wrappedView.appStoreSnapshot, tags: wrappedView.tags, additionalContext: wrappedView.additionalContext, groupOverride: wrappedView.groupOverride, canvasTheme: wrappedView.canvasTheme))
4141
}
4242
}
4343
}

‎Sources/SnapshotPreviewsCore/View+Snapshot.swift‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,33 +50,33 @@ extension View {
5050
a11yWrapper: ((UIViewController, UIWindow, PreviewLayout) -> UIView)? = nil,
5151
completion: @escaping (SnapshotResult) -> Void)
5252
{
53-
controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride, error in
53+
controller.expansionSettled = { [weak controller, weak window] renderingMode, precision, accessibilityEnabled, appStoreSnapshot, tags, additionalContext, groupOverride, canvasTheme, error in
5454
guard let controller, let window, let containerVC = controller.parent else {
5555
return
5656
}
5757

5858
if let error {
5959
DispatchQueue.main.async {
60-
completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
60+
completion(SnapshotResult(image: .failure(error), precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride, canvasTheme: canvasTheme))
6161
}
6262
return
6363
}
6464

6565
if async {
6666
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
6767
let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: controller.view)
68-
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
68+
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride, canvasTheme: canvasTheme))
6969
}
7070
} else {
7171
DispatchQueue.main.async {
7272
if let a11yWrapper, let accessibilityEnabled, accessibilityEnabled {
7373
let a11yView = a11yWrapper(controller, window, layout)
7474
let result = Self.takeSnapshot(layout: .sizeThatFits, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: a11yView)
7575
a11yView.removeFromSuperview()
76-
completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
76+
completion(SnapshotResult(image: result.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride, canvasTheme: canvasTheme))
7777
} else {
7878
let imageResult = Self.takeSnapshot(layout: layout, renderingMode: renderingMode, window: window, rootVC: containerVC, targetView: controller.view)
79-
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride))
79+
completion(SnapshotResult(image: imageResult.mapError { $0 }, precision: precision, accessibilityEnabled: accessibilityEnabled, colorScheme: _colorScheme, appStoreSnapshot: appStoreSnapshot, tags: tags, additionalContext: additionalContext, groupOverride: groupOverride, canvasTheme: canvasTheme))
8080
}
8181
}
8282
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
//
2+
// SnapshotCanvasTheme.swift
3+
//
4+
5+
/// Background canvas theme used when displaying a snapshot image in Sentry.
6+
public enum SnapshotCanvasTheme: String, Codable, Sendable, Equatable {
7+
case light
8+
case dark
9+
}

0 commit comments

Comments
 (0)