diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..159b42e1
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+**/screenshots/**/*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 057a8eee..956dc654 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -22,6 +22,8 @@ jobs:
steps:
- uses: actions/checkout@v4
+ with:
+ lfs: 'true'
- name: Setup JDK
uses: actions/setup-java@v4
@@ -34,6 +36,15 @@ jobs:
- name: Build
run: ./gradlew build
+ - name: Upload reports + Roborazzi outputs
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: reports
+ path: |
+ **/build/reports/**
+ **/build/outputs/roborazzi/**
+
deploy:
if: github.ref == 'refs/heads/main'
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index ec63313a..e4a2b5ee 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -20,6 +20,8 @@ jobs:
steps:
- uses: actions/checkout@v4
+ with:
+ lfs: 'true'
- name: Setup JDK
uses: actions/setup-java@v4
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index e805548a..ae3f30ae 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index c2f7aa97..eed7e11c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -15,5 +15,6 @@ plugins {
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.mavenpublish) apply false
alias(libs.plugins.metalava) apply false
+ alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.dokka)
}
diff --git a/gradle.properties b/gradle.properties
index 92316c96..728c55e7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -27,6 +27,10 @@ systemProp.org.gradle.internal.publish.checksums.insecure=true
# Increase timeout when pushing to Sonatype (otherwise we get timeouts)
systemProp.org.gradle.internal.http.socketTimeout=120000
+roborazzi.record.filePathStrategy=relativePathFromRoborazziContextOutputDirectory
+roborazzi.record.namingStrategy=testClassAndMethod
+roborazzi.test.verify=true
+
##################################
# Publishing
##################################
@@ -49,3 +53,4 @@ POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=chrisbanes
POM_DEVELOPER_NAME=Chris Banes
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 81a8033a..610f7935 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,9 +1,12 @@
[versions]
agp = "8.2.0"
+androidx-test-ext-junit = "1.1.5"
jetpackcompose-compiler = "1.5.6"
compose-multiplatform = "1.5.11"
ktlint = "1.0.1"
kotlin = "1.9.21"
+robolectric = "4.10.3"
+roborazzi = "1.9.0-alpha-3"
spotless = "6.23.3"
[plugins]
@@ -16,6 +19,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-mu
dokka = { id = "org.jetbrains.dokka", version = "1.9.10" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
metalava = { id = "me.tylerbwong.gradle.metalava", version = "0.3.5" }
+roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
mavenpublish = { id = "com.vanniktech.maven.publish", version = "0.26.0" }
@@ -25,6 +29,13 @@ androidx-compose-ui = "androidx.compose.ui:ui:1.6.0-beta03"
androidx-compose-material3 = "androidx.compose.material3:material3:1.1.2"
androidx-core = "androidx.core:core-ktx:1.12.0"
androidx-activity-compose = "androidx.activity:activity-compose:1.8.2"
+androidx-compose-ui-test-manifest = "androidx.compose.ui:ui-test-manifest:1.5.4"
+androidx-test-ext-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-test-ext-junit" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+roborazzi-core = { module = "io.github.takahirom.roborazzi:roborazzi", version.ref = "roborazzi"}
+roborazzi-compose = { module = "io.github.takahirom.roborazzi:roborazzi-compose", version.ref = "roborazzi"}
+roborazzi-composedesktop = { module = "io.github.takahirom.roborazzi:roborazzi-compose-desktop", version.ref = "roborazzi"}
+roborazzi-junit = { module = "io.github.takahirom.roborazzi:roborazzi-junit-rule", version.ref = "roborazzi"}
# Build logic dependencies
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
diff --git a/haze-jetpack-compose/build.gradle.kts b/haze-jetpack-compose/build.gradle.kts
index 3f4de74c..690f296f 100644
--- a/haze-jetpack-compose/build.gradle.kts
+++ b/haze-jetpack-compose/build.gradle.kts
@@ -8,11 +8,21 @@ plugins {
id("org.jetbrains.dokka")
id("com.vanniktech.maven.publish")
id("me.tylerbwong.gradle.metalava")
+ id("io.github.takahirom.roborazzi")
}
android {
namespace = "dev.chrisbanes.haze.jetpackcompose"
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
+ testBuildType = "release"
+
buildFeatures {
compose = true
}
@@ -24,6 +34,9 @@ android {
dependencies {
api(libs.androidx.compose.ui)
+
+ testImplementation(kotlin("test"))
+ testImplementation(projects.internal.screenshotTest)
}
metalava {
diff --git a/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard.png b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard.png
new file mode 100644
index 00000000..2135b807
--- /dev/null
+++ b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e56e562874c4db6ca4d05bde25b4123c6a78f298ce572c518e2e503d97f38d10
+size 886985
diff --git a/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard[28].png b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard[28].png
new file mode 100644
index 00000000..cf30dff1
--- /dev/null
+++ b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard[28].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db7a7eacebcbb363c0a08cc16ddff3063e338819715fa208e7efc5dd60f7014d
+size 882488
diff --git a/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png
new file mode 100644
index 00000000..2135b807
--- /dev/null
+++ b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e56e562874c4db6ca4d05bde25b4123c6a78f298ce572c518e2e503d97f38d10
+size 886985
diff --git a/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png
new file mode 100644
index 00000000..2135b807
--- /dev/null
+++ b/haze-jetpack-compose/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e56e562874c4db6ca4d05bde25b4123c6a78f298ce572c518e2e503d97f38d10
+size 886985
diff --git a/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/HazeNode31.kt b/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/HazeNode31.kt
index fdfd8232..6dc9b6b6 100644
--- a/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/HazeNode31.kt
+++ b/haze-jetpack-compose/src/main/kotlin/dev/chrisbanes/haze/HazeNode31.kt
@@ -110,9 +110,18 @@ internal class HazeNode31(
if (currentValueOf(LocalInspectionMode)) {
// If LocalInspectionMode is true, we're likely running in a preview/screenshot test
// and therefore don't have full access to Android drawing APIs. To avoid crashing we
- // no-op and return early.
+ // just draw the content and return early.
+ drawContent()
return
}
+ drawIntoCanvas { canvas ->
+ // Similar to above, drawRenderNode is only available on hw-accelerated canvases.
+ // To avoid crashing we just draw the content and return early.
+ if (!canvas.nativeCanvas.isHardwareAccelerated) {
+ drawContent()
+ return@draw
+ }
+ }
val contentDrawScope = this
diff --git a/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt b/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt
new file mode 100644
index 00000000..a9909e6a
--- /dev/null
+++ b/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt
@@ -0,0 +1,26 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import dev.chrisbanes.haze.test.ScreenshotTest
+import dev.chrisbanes.haze.test.screenshot
+import kotlin.test.Test
+
+class HazeScreenshotTest : ScreenshotTest() {
+ @Test
+ fun creditCard() = screenshot {
+ MaterialTheme {
+ CreditCardSample()
+ }
+ }
+
+ @Test
+ fun creditCard_transparentTint() = screenshot {
+ MaterialTheme {
+ CreditCardSample(tint = Color.Transparent)
+ }
+ }
+}
diff --git a/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt b/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt
new file mode 100644
index 00000000..e8fa1dea
--- /dev/null
+++ b/haze-jetpack-compose/src/test/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt
@@ -0,0 +1,81 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CreditCardSample(tint: Color? = null) {
+ val hazeState = remember { HazeState() }
+
+ Box {
+ // Background content
+ Box(
+ Modifier
+ .fillMaxSize()
+ .haze(
+ state = hazeState,
+ backgroundColor = Color.Blue,
+ tint = tint ?: Color.White.copy(alpha = 0.1f),
+ blurRadius = 8.dp,
+ ),
+ ) {
+ Spacer(
+ Modifier
+ .fillMaxSize()
+ .background(brush = Brush.linearGradient(colors = listOf(Color.Blue, Color.Cyan))),
+ )
+
+ Text(
+ text = LorumIspum,
+ color = LocalContentColor.current.copy(alpha = 0.2f),
+ modifier = Modifier.padding(24.dp),
+ )
+ }
+
+ // Our card
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .aspectRatio(16 / 9f)
+ .align(Alignment.Center)
+ .hazeChild(state = hazeState, shape = RoundedCornerShape(16.dp)),
+ ) {
+ Column(Modifier.padding(32.dp)) {
+ Text("Bank of Haze")
+ }
+ }
+ }
+}
+
+val LorumIspum by lazy {
+ """
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras sit amet congue mauris, iaculis accumsan eros. Aliquam pulvinar est ac elit vulputate egestas. Vestibulum consequat libero at sem varius, vitae semper urna rhoncus. Aliquam mollis, ipsum a convallis scelerisque, sem dui consequat leo, in tempor risus est ac mi. Nam vel tellus dolor. Nunc lobortis bibendum fermentum. Mauris sed mollis justo, eu tristique elit. Cras semper augue a tortor tempor, vitae vestibulum eros convallis. Curabitur id justo eget tortor iaculis lobortis. Integer pharetra augue ac elit porta iaculis non vitae libero. Nam eros turpis, suscipit at iaculis vitae, malesuada vel arcu. Donec tincidunt porttitor iaculis. Pellentesque non augue magna. Mauris mattis purus vitae mi maximus, id molestie ipsum facilisis. Donec bibendum gravida dolor nec suscipit. Pellentesque tempus felis iaculis, porta diam sed, tristique tortor.
+
+Sed vel tellus vel augue pulvinar semper sit amet eu est. In porta arcu eu sapien luctus scelerisque. In hac habitasse platea dictumst. Aenean varius lobortis malesuada. Sed vitae ornare arcu. Nunc maximus lectus purus, vel aliquet velit facilisis a. Nulla maximus bibendum magna id vulputate. Mauris volutpat lorem et risus porta dignissim. In at elit a est vulputate tincidunt.
+
+Nulla facilisi. Curabitur gravida quam nec massa tempus, sed placerat nunc hendrerit. Duis sit amet cursus ipsum. Phasellus eget congue lacus. Duis vehicula venenatis posuere. Morbi non tempor risus. Aenean bibendum efficitur tortor, eu interdum velit gravida rutrum. Sed tempus elementum libero. Suspendisse dapibus lorem vitae justo congue pellentesque. Phasellus et tellus sagittis, blandit nibh a, porta felis. Proin ornare eget odio eget laoreet. Cras id augue fringilla, molestie ligula sit amet, sollicitudin neque.
+
+Suspendisse vitae bibendum justo, nec egestas mauris. Mauris id metus mi. Morbi ut maximus ex, eu consequat elit. Sed malesuada pellentesque mauris vel molestie. Nulla facilisi. Cras pellentesque metus id nibh sodales gravida. Vivamus a feugiat felis. Vivamus et justo libero. Maecenas ac augue viverra, blandit diam sed, porttitor sapien. Proin eu eros mollis, commodo lectus nec, imperdiet nisi. Proin nulla nulla, vehicula a faucibus sit amet, auctor sed lorem. Mauris ut ipsum sit amet massa posuere maximus eget porttitor nisl. Quisque nunc dolor, pharetra id nunc sit amet, maximus convallis nunc.
+
+Ut magna diam, ullamcorper vel imperdiet at, dignissim sit amet turpis. Duis ut enim eu sapien fringilla placerat. Integer at dui eget leo tincidunt iaculis. Fusce nec elementum turpis. Aenean gravida, ipsum sit amet varius hendrerit, elit nisi hendrerit ex, et porta enim lorem eget mi. Duis convallis dolor a lacinia aliquam. Aliquam erat volutpat.
+""".trim()
+}
diff --git a/haze/build.gradle.kts b/haze/build.gradle.kts
index 4ba048a4..a214c3c1 100644
--- a/haze/build.gradle.kts
+++ b/haze/build.gradle.kts
@@ -9,10 +9,18 @@ plugins {
id("org.jetbrains.dokka")
id("com.vanniktech.maven.publish")
id("me.tylerbwong.gradle.metalava")
+ id("io.github.takahirom.roborazzi")
}
android {
namespace = "dev.chrisbanes.haze"
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+ testOptions {
+ unitTests.isIncludeAndroidResources = true
+ }
+ testBuildType = "release"
}
kotlin {
@@ -38,6 +46,19 @@ kotlin {
val jvmMain by getting {
dependsOn(skikoMain)
}
+
+ val commonTest by getting {
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(projects.internal.screenshotTest)
+ }
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ kotlinOptions {
+ freeCompilerArgs += "-Xcontext-receivers"
}
}
diff --git a/haze/screenshots/android/HazeScreenshotTest.creditCard.png b/haze/screenshots/android/HazeScreenshotTest.creditCard.png
new file mode 100644
index 00000000..cf30dff1
--- /dev/null
+++ b/haze/screenshots/android/HazeScreenshotTest.creditCard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db7a7eacebcbb363c0a08cc16ddff3063e338819715fa208e7efc5dd60f7014d
+size 882488
diff --git a/haze/screenshots/android/HazeScreenshotTest.creditCard[28].png b/haze/screenshots/android/HazeScreenshotTest.creditCard[28].png
new file mode 100644
index 00000000..cf30dff1
--- /dev/null
+++ b/haze/screenshots/android/HazeScreenshotTest.creditCard[28].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:db7a7eacebcbb363c0a08cc16ddff3063e338819715fa208e7efc5dd60f7014d
+size 882488
diff --git a/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png b/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png
new file mode 100644
index 00000000..2135b807
--- /dev/null
+++ b/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e56e562874c4db6ca4d05bde25b4123c6a78f298ce572c518e2e503d97f38d10
+size 886985
diff --git a/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png b/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png
new file mode 100644
index 00000000..2135b807
--- /dev/null
+++ b/haze/screenshots/android/HazeScreenshotTest.creditCard_transparentTint[28].png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e56e562874c4db6ca4d05bde25b4123c6a78f298ce572c518e2e503d97f38d10
+size 886985
diff --git a/haze/screenshots/desktop/HazeScreenshotTest.creditCard.png b/haze/screenshots/desktop/HazeScreenshotTest.creditCard.png
new file mode 100644
index 00000000..3aeb923d
--- /dev/null
+++ b/haze/screenshots/desktop/HazeScreenshotTest.creditCard.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4a2e37d26f0763257543e98528997690041f065be3043f8ffd6dc9d338434a95
+size 544567
diff --git a/haze/screenshots/desktop/HazeScreenshotTest.creditCard_transparentTint.png b/haze/screenshots/desktop/HazeScreenshotTest.creditCard_transparentTint.png
new file mode 100644
index 00000000..39e70257
--- /dev/null
+++ b/haze/screenshots/desktop/HazeScreenshotTest.creditCard_transparentTint.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:395ee08e35b41f2cba54d7b8279922f65cc8fbf03bc9538739259c50aaafa111
+size 550631
diff --git a/haze/src/commonTest/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt b/haze/src/commonTest/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt
new file mode 100644
index 00000000..a9909e6a
--- /dev/null
+++ b/haze/src/commonTest/kotlin/dev/chrisbanes/haze/HazeScreenshotTest.kt
@@ -0,0 +1,26 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import dev.chrisbanes.haze.test.ScreenshotTest
+import dev.chrisbanes.haze.test.screenshot
+import kotlin.test.Test
+
+class HazeScreenshotTest : ScreenshotTest() {
+ @Test
+ fun creditCard() = screenshot {
+ MaterialTheme {
+ CreditCardSample()
+ }
+ }
+
+ @Test
+ fun creditCard_transparentTint() = screenshot {
+ MaterialTheme {
+ CreditCardSample(tint = Color.Transparent)
+ }
+ }
+}
diff --git a/haze/src/commonTest/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt b/haze/src/commonTest/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt
new file mode 100644
index 00000000..e8fa1dea
--- /dev/null
+++ b/haze/src/commonTest/kotlin/dev/chrisbanes/haze/ScreenshotTestContent.kt
@@ -0,0 +1,81 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun CreditCardSample(tint: Color? = null) {
+ val hazeState = remember { HazeState() }
+
+ Box {
+ // Background content
+ Box(
+ Modifier
+ .fillMaxSize()
+ .haze(
+ state = hazeState,
+ backgroundColor = Color.Blue,
+ tint = tint ?: Color.White.copy(alpha = 0.1f),
+ blurRadius = 8.dp,
+ ),
+ ) {
+ Spacer(
+ Modifier
+ .fillMaxSize()
+ .background(brush = Brush.linearGradient(colors = listOf(Color.Blue, Color.Cyan))),
+ )
+
+ Text(
+ text = LorumIspum,
+ color = LocalContentColor.current.copy(alpha = 0.2f),
+ modifier = Modifier.padding(24.dp),
+ )
+ }
+
+ // Our card
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.7f)
+ .aspectRatio(16 / 9f)
+ .align(Alignment.Center)
+ .hazeChild(state = hazeState, shape = RoundedCornerShape(16.dp)),
+ ) {
+ Column(Modifier.padding(32.dp)) {
+ Text("Bank of Haze")
+ }
+ }
+ }
+}
+
+val LorumIspum by lazy {
+ """
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras sit amet congue mauris, iaculis accumsan eros. Aliquam pulvinar est ac elit vulputate egestas. Vestibulum consequat libero at sem varius, vitae semper urna rhoncus. Aliquam mollis, ipsum a convallis scelerisque, sem dui consequat leo, in tempor risus est ac mi. Nam vel tellus dolor. Nunc lobortis bibendum fermentum. Mauris sed mollis justo, eu tristique elit. Cras semper augue a tortor tempor, vitae vestibulum eros convallis. Curabitur id justo eget tortor iaculis lobortis. Integer pharetra augue ac elit porta iaculis non vitae libero. Nam eros turpis, suscipit at iaculis vitae, malesuada vel arcu. Donec tincidunt porttitor iaculis. Pellentesque non augue magna. Mauris mattis purus vitae mi maximus, id molestie ipsum facilisis. Donec bibendum gravida dolor nec suscipit. Pellentesque tempus felis iaculis, porta diam sed, tristique tortor.
+
+Sed vel tellus vel augue pulvinar semper sit amet eu est. In porta arcu eu sapien luctus scelerisque. In hac habitasse platea dictumst. Aenean varius lobortis malesuada. Sed vitae ornare arcu. Nunc maximus lectus purus, vel aliquet velit facilisis a. Nulla maximus bibendum magna id vulputate. Mauris volutpat lorem et risus porta dignissim. In at elit a est vulputate tincidunt.
+
+Nulla facilisi. Curabitur gravida quam nec massa tempus, sed placerat nunc hendrerit. Duis sit amet cursus ipsum. Phasellus eget congue lacus. Duis vehicula venenatis posuere. Morbi non tempor risus. Aenean bibendum efficitur tortor, eu interdum velit gravida rutrum. Sed tempus elementum libero. Suspendisse dapibus lorem vitae justo congue pellentesque. Phasellus et tellus sagittis, blandit nibh a, porta felis. Proin ornare eget odio eget laoreet. Cras id augue fringilla, molestie ligula sit amet, sollicitudin neque.
+
+Suspendisse vitae bibendum justo, nec egestas mauris. Mauris id metus mi. Morbi ut maximus ex, eu consequat elit. Sed malesuada pellentesque mauris vel molestie. Nulla facilisi. Cras pellentesque metus id nibh sodales gravida. Vivamus a feugiat felis. Vivamus et justo libero. Maecenas ac augue viverra, blandit diam sed, porttitor sapien. Proin eu eros mollis, commodo lectus nec, imperdiet nisi. Proin nulla nulla, vehicula a faucibus sit amet, auctor sed lorem. Mauris ut ipsum sit amet massa posuere maximus eget porttitor nisl. Quisque nunc dolor, pharetra id nunc sit amet, maximus convallis nunc.
+
+Ut magna diam, ullamcorper vel imperdiet at, dignissim sit amet turpis. Duis ut enim eu sapien fringilla placerat. Integer at dui eget leo tincidunt iaculis. Fusce nec elementum turpis. Aenean gravida, ipsum sit amet varius hendrerit, elit nisi hendrerit ex, et porta enim lorem eget mi. Duis convallis dolor a lacinia aliquam. Aliquam erat volutpat.
+""".trim()
+}
diff --git a/internal/screenshot-test/build.gradle.kts b/internal/screenshot-test/build.gradle.kts
new file mode 100644
index 00000000..8a57b270
--- /dev/null
+++ b/internal/screenshot-test/build.gradle.kts
@@ -0,0 +1,73 @@
+// Copyright 2023, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+
+plugins {
+ id("dev.chrisbanes.android.library")
+ id("dev.chrisbanes.kotlin.multiplatform")
+ id("dev.chrisbanes.compose")
+ id("io.github.takahirom.roborazzi")
+}
+
+android {
+ namespace = "dev.chrisbanes.haze.internal.screenshot"
+}
+
+kotlin {
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ api(compose.foundation)
+ api(compose.material3)
+ }
+ }
+
+ val commonJvmMain by creating {
+ dependsOn(commonMain)
+
+ dependencies {
+ api(libs.roborazzi.core)
+ }
+ }
+
+ val androidMain by getting {
+ dependsOn(commonJvmMain)
+
+ dependencies {
+ implementation(libs.androidx.test.ext.junit)
+ implementation(libs.androidx.compose.ui.test.manifest)
+
+ @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
+ implementation(compose.uiTestJUnit4)
+
+ implementation(libs.robolectric)
+
+ implementation(libs.roborazzi.compose)
+ implementation(libs.roborazzi.junit)
+ }
+ }
+
+ val jvmMain by getting {
+ dependsOn(commonJvmMain)
+
+ dependencies {
+ implementation(compose.desktop.currentOs)
+ implementation(libs.roborazzi.composedesktop)
+ }
+ }
+ }
+
+ targets.configureEach {
+ compilations.configureEach {
+ compilerOptions.configure {
+ freeCompilerArgs.add("-Xexpect-actual-classes")
+ }
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ kotlinOptions {
+ freeCompilerArgs += "-Xcontext-receivers"
+ }
+}
diff --git a/internal/screenshot-test/src/androidMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt b/internal/screenshot-test/src/androidMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
new file mode 100644
index 00000000..22b47369
--- /dev/null
+++ b/internal/screenshot-test/src/androidMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
@@ -0,0 +1,41 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze.test
+
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
+import com.github.takahirom.roborazzi.RoborazziRule
+import com.github.takahirom.roborazzi.captureRoboImage
+import org.junit.Rule
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [28, 33], qualifiers = RobolectricDeviceQualifiers.Pixel5)
+actual abstract class ScreenshotTest {
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule()
+
+ @get:Rule
+ val roborazziRule = RoborazziRule(
+ composeRule = composeTestRule,
+ captureRoot = composeTestRule.onRoot(),
+ options = RoborazziRule.Options(
+ outputDirectoryPath = "screenshots/${HazeRoborazziDefaults.outputDirectoryName}",
+ roborazziOptions = HazeRoborazziDefaults.roborazziOptions,
+ ),
+ )
+}
+
+actual val HazeRoborazziDefaults.outputDirectoryName: String get() = "android"
+
+actual fun ScreenshotTest.screenshot(content: @Composable () -> Unit) {
+ captureRoboImage(content = content)
+}
diff --git a/internal/screenshot-test/src/commonJvmMain/kotlin/dev/chrisbanes/haze/test/HazeRoborazziDefaults.kt b/internal/screenshot-test/src/commonJvmMain/kotlin/dev/chrisbanes/haze/test/HazeRoborazziDefaults.kt
new file mode 100644
index 00000000..02b1e3dd
--- /dev/null
+++ b/internal/screenshot-test/src/commonJvmMain/kotlin/dev/chrisbanes/haze/test/HazeRoborazziDefaults.kt
@@ -0,0 +1,20 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze.test
+
+import com.dropbox.differ.SimpleImageComparator
+import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
+import com.github.takahirom.roborazzi.RoborazziOptions
+
+@OptIn(ExperimentalRoborazziApi::class)
+object HazeRoborazziDefaults {
+ val roborazziOptions = RoborazziOptions(
+ compareOptions = RoborazziOptions.CompareOptions(
+ changeThreshold = 0.01f,
+ imageComparator = SimpleImageComparator(hShift = 1, vShift = 1),
+ ),
+ )
+}
+
+expect val HazeRoborazziDefaults.outputDirectoryName: String
diff --git a/internal/screenshot-test/src/commonMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt b/internal/screenshot-test/src/commonMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
new file mode 100644
index 00000000..9974a3f9
--- /dev/null
+++ b/internal/screenshot-test/src/commonMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
@@ -0,0 +1,10 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze.test
+
+import androidx.compose.runtime.Composable
+
+expect abstract class ScreenshotTest()
+
+expect fun ScreenshotTest.screenshot(content: @Composable () -> Unit)
diff --git a/internal/screenshot-test/src/iosMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt b/internal/screenshot-test/src/iosMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
new file mode 100644
index 00000000..c21007a9
--- /dev/null
+++ b/internal/screenshot-test/src/iosMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
@@ -0,0 +1,12 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze.test
+
+import androidx.compose.runtime.Composable
+
+actual abstract class ScreenshotTest
+
+actual fun ScreenshotTest.screenshot(content: @Composable () -> Unit) {
+ // no-op on iOS
+}
diff --git a/internal/screenshot-test/src/jvmMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt b/internal/screenshot-test/src/jvmMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
new file mode 100644
index 00000000..e01529a5
--- /dev/null
+++ b/internal/screenshot-test/src/jvmMain/kotlin/dev/chrisbanes/haze/test/ScreenshotTest.kt
@@ -0,0 +1,50 @@
+// Copyright 2024, Christopher Banes and the Haze project contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package dev.chrisbanes.haze.test
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.test.DesktopComposeUiTest
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.unit.Density
+import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
+import com.github.takahirom.roborazzi.InternalRoborazziApi
+import com.github.takahirom.roborazzi.RoborazziOptions
+import com.github.takahirom.roborazzi.provideRoborazziContext
+import io.github.takahirom.roborazzi.captureRoboImage
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+actual abstract class ScreenshotTest
+
+actual val HazeRoborazziDefaults.outputDirectoryName: String get() = "desktop"
+
+actual fun ScreenshotTest.screenshot(content: @Composable () -> Unit) {
+ @OptIn(ExperimentalTestApi::class)
+ captureRoborazziImage(content = content)
+}
+
+@OptIn(ExperimentalRoborazziApi::class, InternalRoborazziApi::class)
+@ExperimentalTestApi
+private fun captureRoborazziImage(
+ width: Int = 1080,
+ height: Int = 1920,
+ density: Density = Density(2.75f),
+ roborazziOptions: RoborazziOptions = HazeRoborazziDefaults.roborazziOptions,
+ effectContext: CoroutineContext = EmptyCoroutineContext,
+ content: @Composable () -> Unit,
+) {
+ DesktopComposeUiTest(
+ width = width,
+ height = height,
+ effectContext = effectContext,
+ density = density,
+ ).runTest {
+ provideRoborazziContext().apply {
+ setRuleOverrideOutputDirectory("screenshots/desktop")
+ }
+ setContent(content)
+ onRoot().captureRoboImage(roborazziOptions = roborazziOptions)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index de945447..3be2a33f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -44,6 +44,7 @@ rootProject.name = "haze-root"
include(
":haze",
":haze-jetpack-compose",
+ ":internal:screenshot-test",
":sample:shared",
":sample:android",
":sample:android-jetpack",