Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ The library is an **engine abstraction over a snapshot backend**.
- `Component` — rendered at minimal size (Paparazzi `SHRINK`), device-independent.
- `Screen` — rendered in the full device frame (Paparazzi `NORMAL`), once per configured `DeviceConfig`.
The device is a **separate axis** (not baked into the kind): enable it via `screens(vararg DeviceConfig)`.
- **Config model**: `DeviceConfig(Device, DeviceOrientation)`, `Device` (PIXEL_6, NEXUS_10) with
`Device.Pixel6`/`Device.Nexus10` `.portrait`/`.landscape` `DeviceConfig` shortcuts, `UiMode`
- **Config model**: `DeviceConfig(Device, DeviceOrientation)`, `Device` (PIXEL_6, NEXUS_10,
PIXEL_9_PRO_FOLD) with `Device.Pixel6`/`Device.Nexus10`/`Device.Pixel9ProFold` `.portrait`/`.landscape`
`DeviceConfig` shortcuts, `UiMode`
(LIGHT/DARK → NightMode), `FontScale`. `SnapshotVariant(kind, device?, uiMode, fontScale)` is the
resolved coordinate and the `exclude` predicate receiver.
- **Selection is data, not types**: consumers tag a `@ShowkaseComposable` with
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

Adds the Pixel 9 Pro Fold's unfolded inner display as a snapshot device. An unfolded foldable is wider
than Material's Expanded breakpoint (840dp) but much narrower than a tablet in landscape, so a layout
that branches on width can behave differently there than on either device already offered.

### annotations
- Added the `PreviewDevice.Pixel9ProFoldPortrait` / `PreviewDevice.Pixel9ProFoldLandscape` inline
override tokens.

### framework
- Added `Device.PIXEL_9_PRO_FOLD` with the `Device.Pixel9ProFold.portrait` / `.landscape` `DeviceConfig`
shortcuts, and mapped the new inline tokens to them. The snapshot-name label is `Pixel9ProFold`.

### paparazzi
- Mapped `Device.PIXEL_9_PRO_FOLD` to Paparazzi's own `PIXEL_9_PRO_FOLD` device (2076x2152px at 390dpi,
i.e. 852x883dp).

## BOM [3.2.0-paparazzialpha05] - 2026-09-07

Adds support for androidx's `@PreviewWrapper`: a preview that declares a `PreviewWrapperProvider` —
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,11 @@ The DSL surface:
| `profile(key) { }` | variants | no (0..n) | Define a named per-preview override (see below). |

Ready-made device shortcuts live under the `Device` enum: `Device.Pixel6.portrait`, `Device.Pixel6.landscape`, `Device.Nexus10.portrait`,
`Device.Nexus10.landscape` — each a `DeviceConfig`.
`Device.Nexus10.landscape`, `Device.Pixel9ProFold.portrait`, `Device.Pixel9ProFold.landscape` — each a `DeviceConfig`.

`Device.Pixel9ProFold` is the unfolded inner display (852x883dp). It is worth adding when your layout branches on width: an unfolded foldable is
wider than Material's Expanded breakpoint (840dp) yet far narrower than a tablet in landscape, and it is nearly square, so portrait and landscape
land on the same side of most breakpoints. That band is where width-driven layouts tend to break first.

### Excluding coupled cells

Expand Down Expand Up @@ -276,8 +280,8 @@ fun LoginScreenPreview() { LoginScreen() }
```

Available tokens (all in the `annotations` module): `PreviewFontScale.{Small, Normal, Large}`, `PreviewUiMode.{Light, Dark}`,
`PreviewDevice.{Pixel6Portrait, Pixel6Landscape, Nexus10Portrait, Nexus10Landscape}`. Tagging multiple values of the same axis sets that axis to the
set (e.g. two `PreviewFontScale` tokens ⇒ both scales).
`PreviewDevice.{Pixel6Portrait, Pixel6Landscape, Nexus10Portrait, Nexus10Landscape, Pixel9ProFoldPortrait, Pixel9ProFoldLandscape}`. Tagging multiple
values of the same axis sets that axis to the set (e.g. two `PreviewFontScale` tokens ⇒ both scales).

**Named profiles** — when several previews share the same exception, define it once as a `profile(key)` and reference the `key` from each preview. Keep
the key in a shared `const` so the tag and the profile can't drift apart:
Expand Down
2 changes: 2 additions & 0 deletions annotations/api/annotations.api
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public final class io/github/ackeecz/snapshots/annotations/PreviewDevice {
public static final field Nexus10Portrait Ljava/lang/String;
public static final field Pixel6Landscape Ljava/lang/String;
public static final field Pixel6Portrait Ljava/lang/String;
public static final field Pixel9ProFoldLandscape Ljava/lang/String;
public static final field Pixel9ProFoldPortrait Ljava/lang/String;
}

public final class io/github/ackeecz/snapshots/annotations/PreviewFontScale {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ object PreviewDevice {
const val Pixel6Landscape = "device=PIXEL_6_LANDSCAPE"
const val Nexus10Portrait = "device=NEXUS_10_PORTRAIT"
const val Nexus10Landscape = "device=NEXUS_10_LANDSCAPE"
const val Pixel9ProFoldPortrait = "device=PIXEL_9_PRO_FOLD_PORTRAIT"
const val Pixel9ProFoldLandscape = "device=PIXEL_9_PRO_FOLD_LANDSCAPE"
}
8 changes: 8 additions & 0 deletions framework/api/framework.api
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public final class io/github/ackeecz/snapshots/framework/ComposableSingletons$Sn
public final class io/github/ackeecz/snapshots/framework/Device : java/lang/Enum {
public static final field NEXUS_10 Lio/github/ackeecz/snapshots/framework/Device;
public static final field PIXEL_6 Lio/github/ackeecz/snapshots/framework/Device;
public static final field PIXEL_9_PRO_FOLD Lio/github/ackeecz/snapshots/framework/Device;
public static fun getEntries ()Lkotlin/enums/EnumEntries;
public static fun valueOf (Ljava/lang/String;)Lio/github/ackeecz/snapshots/framework/Device;
public static fun values ()[Lio/github/ackeecz/snapshots/framework/Device;
Expand All @@ -31,6 +32,13 @@ public final class io/github/ackeecz/snapshots/framework/Device$Pixel6 {
public final fun getPortrait ()Lio/github/ackeecz/snapshots/framework/DeviceConfig;
}

public final class io/github/ackeecz/snapshots/framework/Device$Pixel9ProFold {
public static final field $stable I
public static final field INSTANCE Lio/github/ackeecz/snapshots/framework/Device$Pixel9ProFold;
public final fun getLandscape ()Lio/github/ackeecz/snapshots/framework/DeviceConfig;
public final fun getPortrait ()Lio/github/ackeecz/snapshots/framework/DeviceConfig;
}

public final class io/github/ackeecz/snapshots/framework/DeviceConfig {
public static final field $stable I
public fun <init> (Lio/github/ackeecz/snapshots/framework/Device;Lio/github/ackeecz/snapshots/framework/DeviceOrientation;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ internal val Device.label: String
get() = when (this) {
Device.PIXEL_6 -> "Pixel6"
Device.NEXUS_10 -> "Nexus10"
Device.PIXEL_9_PRO_FOLD -> "Pixel9ProFold"
}

internal val DeviceOrientation.label: String
Expand All @@ -31,7 +32,10 @@ enum class Device {
PIXEL_6,

// tablets
NEXUS_10;
NEXUS_10,

// foldables — unfolded inner display
PIXEL_9_PRO_FOLD;

/** Ready-made [DeviceConfig] shortcuts for the Pixel 6 phone. */
object Pixel6 {
Expand All @@ -52,6 +56,20 @@ enum class Device {
/** Nexus 10 in landscape orientation. */
val landscape: DeviceConfig = DeviceConfig(device = NEXUS_10, orientation = DeviceOrientation.LANDSCAPE)
}

/**
* Ready-made [DeviceConfig] shortcuts for the Pixel 9 Pro Fold's unfolded inner display
* (852x883dp). Both orientations sit between the tablet and phone widths, which is the band a
* layout driven by width breakpoints is most likely to get wrong.
*/
object Pixel9ProFold {

/** Pixel 9 Pro Fold, unfolded, in portrait orientation. */
val portrait: DeviceConfig = DeviceConfig(device = PIXEL_9_PRO_FOLD, orientation = DeviceOrientation.PORTRAIT)

/** Pixel 9 Pro Fold, unfolded, in landscape orientation. */
val landscape: DeviceConfig = DeviceConfig(device = PIXEL_9_PRO_FOLD, orientation = DeviceOrientation.LANDSCAPE)
}
}

enum class DeviceOrientation {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ internal class ExtraMetadataParser {
PreviewDevice.Pixel6Landscape to Device.Pixel6.landscape,
PreviewDevice.Nexus10Portrait to Device.Nexus10.portrait,
PreviewDevice.Nexus10Landscape to Device.Nexus10.landscape,
PreviewDevice.Pixel9ProFoldPortrait to Device.Pixel9ProFold.portrait,
PreviewDevice.Pixel9ProFoldLandscape to Device.Pixel9ProFold.landscape,
)

fun parse(extraMetadata: List<String>, profileKeys: Set<String>): ParsedMetadata {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.ackeecz.snapshots.framework

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldNotContain

internal class DeviceShortcutTest : FunSpec({

Expand All @@ -17,6 +18,17 @@ internal class DeviceShortcutTest : FunSpec({
Device.Nexus10.portrait shouldBe expectedConfig(Device.NEXUS_10, DeviceOrientation.PORTRAIT)
Device.Nexus10.landscape shouldBe expectedConfig(Device.NEXUS_10, DeviceOrientation.LANDSCAPE)
}

test("Device.Pixel9ProFold.portrait / landscape map to PIXEL_9_PRO_FOLD") {
Device.Pixel9ProFold.portrait shouldBe expectedConfig(Device.PIXEL_9_PRO_FOLD, DeviceOrientation.PORTRAIT)
Device.Pixel9ProFold.landscape shouldBe expectedConfig(Device.PIXEL_9_PRO_FOLD, DeviceOrientation.LANDSCAPE)
}

test("every device has a snapshot-name label that is unique and free of underscores") {
val labels = Device.entries.map { DeviceConfig(it, DeviceOrientation.PORTRAIT).name }
labels.forEach { it shouldNotContain "_" }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use forAll instead of forEach

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 f0cfb31

labels shouldBe labels.distinct()
}
})

private fun expectedConfig(device: Device, orientation: DeviceOrientation) =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ internal class ExtraMetadataParserTest : FunSpec({
parse(PreviewDevice.Pixel6Landscape).inlineDevices shouldBe setOf(Device.Pixel6.landscape)
parse(PreviewDevice.Nexus10Portrait).inlineDevices shouldBe setOf(Device.Nexus10.portrait)
parse(PreviewDevice.Nexus10Landscape).inlineDevices shouldBe setOf(Device.Nexus10.landscape)
parse(PreviewDevice.Pixel9ProFoldPortrait).inlineDevices shouldBe setOf(Device.Pixel9ProFold.portrait)
parse(PreviewDevice.Pixel9ProFoldLandscape).inlineDevices shouldBe setOf(Device.Pixel9ProFold.landscape)
}

test("multiple device tokens yield the set") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ internal fun renderConfigFor(kind: SnapshotKind, device: DeviceConfig?): Paparaz
private fun mapDevice(device: Device): PaparazziDeviceConfig = when (device) {
Device.PIXEL_6 -> PaparazziDeviceConfig.PIXEL_6
Device.NEXUS_10 -> PaparazziDeviceConfig.NEXUS_10
Device.PIXEL_9_PRO_FOLD -> PaparazziDeviceConfig.PIXEL_9_PRO_FOLD
}

private fun mapOrientation(orientation: DeviceOrientation): ScreenOrientation = when (orientation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ class PaparazziMappingTest : FunSpec({
renderConfigFor(SnapshotKind.Screen, device).deviceConfig shouldBe
PaparazziDeviceConfig.NEXUS_10.copy(orientation = ScreenOrientation.PORTRAIT)
}
test("a Pixel 9 Pro Fold screen maps to the unfolded fold device") {
val device = DeviceConfig(Device.PIXEL_9_PRO_FOLD, DeviceOrientation.PORTRAIT)
renderConfigFor(SnapshotKind.Screen, device).deviceConfig shouldBe
PaparazziDeviceConfig.PIXEL_9_PRO_FOLD.copy(orientation = ScreenOrientation.PORTRAIT)
}
test("Screen orientation is applied to the device config") {
val device = DeviceConfig(Device.PIXEL_6, DeviceOrientation.LANDSCAPE)
renderConfigFor(SnapshotKind.Screen, device).deviceConfig.orientation shouldBe ScreenOrientation.LANDSCAPE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.github.ackeecz.snapshots

import io.github.ackeecz.snapshots.framework.Device
import io.github.ackeecz.snapshots.framework.FontScale
import io.github.ackeecz.snapshots.framework.UiMode
import io.github.ackeecz.snapshots.paparazzi.PaparazziSnapshotTests
import io.github.ackeecz.snapshots.ui.PreviewGroup

/**
* Device concern: the `Default` group's two screens rendered on the Pixel 9 Pro Fold's unfolded inner
* display, in both orientations — the end-to-end proof that the fold device reaches Paparazzi and
* renders, which the pure mapping unit test cannot show.
*
* The fold is the interesting device to cover because it is nearly square (852x883dp): it is wider than
* Material's Expanded breakpoint yet far narrower than a tablet in landscape, and both orientations land
* on the same side of most width breakpoints.
*
* One UI mode and one font scale keep this at **2 screens × 2 orientations + 1 component = 5 goldens**;
* the remaining axes are already covered by [DefaultConfigSnapshotTests]. `components()` is enabled
* because the `Default` group also holds a Component-tagged preview, and a preview that resolves to zero
* variants is a config error — the component cell rides along device-independently.
*/
class FoldDeviceSnapshotTests : PaparazziSnapshotTests({
previews(previewsInGroup(PreviewGroup.Default))
decorate(sampleDecorate)
variants {
components()
screens(Device.Pixel9ProFold.portrait, Device.Pixel9ProFold.landscape)
uiModes(UiMode.LIGHT)
fontScales(FontScale.NORMAL)
}
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading