Arkive turns your Compose previews and XML views into a browsable web catalogue of your app's UI — recorded on the JVM with Roborazzi (or optionally Paparazzi), no device or emulator, no test code to write.
Screen.Recording.2026-08-11.at.9.04.12.PM.mov
Live demo: the sample app's catalogue is deployed to
GitHub Pages on every merge to main.
- AGP 9 — the plugin is compiled against AGP 9.3.1 and targets the AGP 9.x line (which itself requires Gradle 9.1+ and Kotlin 2.2.10+). Projects still on AGP 8 should use Arkive 0.0.3.
- Kotlin 2.2.10 or newer for plain Android/JVM consumers (AGP 9's own Kotlin
floor).
@ArkiveComposableincommonMainof a KMP module needs Kotlin 2.3.21+ — on older Kotlin the annotations wire intoandroidMaininstead, and plain@PreviewincommonMainstill works. - Gradle JDK 17 or newer with the Roborazzi engine; the Paparazzi engine needs a JDK 21+ Gradle daemon (see Engines).
- KSP applied to every module using Arkive.
- Kotlin Multiplatform modules use the
com.android.kotlin.multiplatform.librarylayout — see Kotlin Multiplatform. The classiccom.android.library+androidTarget()layout is rejected by AGP 9 itself; those modules should stay on Arkive 0.0.3.
The fastest way to adopt Arkive is to let your coding agent do it. This repo ships agent skills (the open SKILL.md standard) that install Arkive correctly — right version, every module with previews, flavor-aware configuration — and end with the catalogue open in your browser.
/plugin marketplace add infinum/arkive
/plugin install arkive@arkive
codex plugin marketplace add infinum/arkive
then inside Codex:
/plugin install arkive@arkive
Cursor, Gemini CLI, Copilot, … — one command, works with any agent that supports skills:
npx skills add infinum/arkive
Whichever install option you chose, finish with this step: ask your agent to set up Arkive, or call the skill directly:
/arkive:setup
The
/arkive:prefix comes from the plugin installs. Withnpx skills addthe skills are unprefixed — call/setup,/annotate,/find,/design-loop,/snapshot-testinginstead.
| Skill | What it does |
|---|---|
/arkive:setup |
Installs the latest published version (pinned), applies Arkive to every module with previews, configures flavors, fixes the common silent traps (private previews, empty test source sets), generates the first catalogue, and opens it in your browser |
/arkive:annotate |
Adds or edits @ArkiveComposable / @ArkiveView following consistent naming, grouping, and tagging conventions — so the catalogue sidebar stays clean as the team grows it |
/arkive:find |
The reuse gate: before building a "new" screen or component, searches the catalogue and source for an existing implementation, visually compares the candidates, and answers use-it / extend-it / build-new |
/arkive:design-loop |
Implement → regenerate → visually compare each screen against its Figma frame (or your spec) → fix or stop-and-ask. Uses the designNodeId annotations to find the right Figma node automatically |
/arkive:snapshot-testing |
Turns the catalogue into a regression net: enables golden retention, records and verifies goldens, and diagnoses verifyShowcase failures |
Apply the plugin to every Android module whose previews you want in the catalogue:
plugins {
id("com.google.devtools.ksp") version "<ksp version>" // required — Arkive runs on KSP
id("com.infinum.arkive") version "<latest version>"
}And to the root project (this registers the aggregate generateWebShowcase task —
mandatory if your build uses org.gradle.configureondemand):
// root build.gradle.kts
plugins {
id("com.infinum.arkive") version "<latest version>"
}An arkive { engine(…) } block per module is mandatory — the build fails until you
choose a snapshot engine (see Engines). Don't apply Roborazzi or Paparazzi
yourself — Arkive brings its own. Toolchain prerequisites are listed under
Requirements.
Arkive works on KMP modules using the com.android.kotlin.multiplatform.library
plugin (the AGP 9 layout). Previews in commonMain — plain CMP @Previews,
@ArkiveComposable, and @PreviewParameter in either the androidx or jetbrains
namespace — are recorded through the android target, exactly like android ones. (The
classic com.android.library + androidTarget() layout is rejected by AGP 9 itself;
projects on that layout should stay on Arkive 0.0.3, which supports it on AGP 8.) The
module needs a couple of extra lines:
plugins {
id("org.jetbrains.kotlin.multiplatform")
id("com.android.kotlin.multiplatform.library")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.compose")
id("com.google.devtools.ksp") version "<2.3.6 or newer>"
id("com.infinum.arkive") version "<latest version>"
}
kotlin {
androidLibrary {
namespace = "com.example.shared"
compileSdk = 36
minSdk = 24
withHostTestBuilder {}.configure {
isIncludeAndroidResources = true // snapshots render in the host tests
}
}
}KMP modules have a single android variant named androidMain, so the tasks are
generateShowcaseAndroidMain / verifyShowcaseAndroidMain, goldens live in
src/androidHostTest/snapshots, and multiModuleVariant needs no configuration.
Arkive enables the library's android resources itself (snapshot rendering needs the
module's R class) and wires all KSP/test dependencies.
Two constraints:
- The unit-test source set needs at least one source file of its own — KSP skips
empty compilations, and Arkive's generated snapshot test with them. Any real test
works, or drop an
internal object ArkivePlaceholderintosrc/androidHostTest/kotlin(new plugin) /src/androidUnitTest/kotlin(classic layout) — seesampleCmp. @ArkiveComposableincommonMainworks on any Kotlin 2.3.21+ project — klibs are not forward-compatible, so the floor is the Kotlin the annotations were built with. On an older Kotlin the plugin wires the annotations intoandroidMaininstead and logs it; plain@Previews incommonMainare still collected, since they need no Arkive dependency.
See sampleCmp for a complete working module.
Generate and view:
./gradlew generateWebShowcase
Then in Android Studio, right-click build/generated/arkive/showcase/index.html →
Open In → Browser. (The IDE serves it over its built-in web server; double-clicking
the file in Finder/Explorer won't work — the catalogue fetches its data, which file://
blocks. Any static file server works too.)
Everything lives in the arkive { } block, per module:
arkive {
multiModuleVariant.set("uatDebug") // which variant the root task builds for this module —
// REQUIRED if the module has product flavors (defaults to "debug")
enableVariants.set(true) // also record font-scale / density / RTL variants (slower)
enablePreviewParameters.set(true) // expand @PreviewParameter values (default true)
designFileKey.set("AbC123") // your Figma file key — enables per-component Figma links
snapshotRetention.set(SnapshotRetention.NONE) // see Snapshot testing below
engine(Roborazzi) { // REQUIRED — every module picks its engine (see Engines)
device.set("w1280dp-h800dp-mdpi") // the device snapshots render on, as Robolectric
// qualifiers (default: a Pixel-6-class phone)
}
// or: engine(Paparazzi) // requires a JDK 21+ Gradle daemon
}Arkive records snapshots through one of two engines, and every module must choose one — there is no default, and the build fails with instructions until you pick. Selecting an engine and configuring it is a single call, so options for an engine the module doesn't run are unrepresentable:
arkive {
engine(Roborazzi) { device.set("w1280dp-h800dp-mdpi") }
// or: engine(Paparazzi)
}The arkive.engine Gradle property (module or root gradle.properties, or
-Parkive.engine=) overrides the DSL — useful for flipping engines per-run or
pinning one org-wide without editing build files:
arkive.engine=roborazzi # or paparazzi- your Gradle JDK is 17 (Studio's Gradle JDK setting; many organizations pin it) — Roborazzi is the only engine that works there;
- the module is Compose Multiplatform and uses
composeResources(stringResource/painterResource) — Robolectric provides a real Android context, so CMP resources render; layoutlib cannot do this at all; - you want per-module device control: snapshots render on a real (simulated) device —
a Pixel-6-class phone unless configured, e.g. a 10" tablet
w1280dp-h800dp-mdpior a desktop-likew1920dp-h1080dp-mdpi. Components capture at content size, screens at device size; - you want per-component test reporting (one test per snapshot) and flat memory use on modules of any size.
Roborazzi renders with real framework code via Robolectric — very close to on-device rendering, not pixel-identical to Studio previews.
- your Gradle JDK is 21+ (hard requirement — Paparazzi ships Java 21 bytecode since 2.0.0-alpha03; on an older daemon Arkive fails with a clear error);
- you want snapshots pixel-identical to Android Studio previews (layoutlib is the same renderer);
- recording speed matters most: ~52ms vs ~93ms per snapshot in our benchmark (identical content, Apple silicon).
Paparazzi and Compose Multiplatform: plain commonMain previews render fine, but any
preview that reads CMP resources (stringResource/painterResource from
composeResources) fails with "Android context is not initialized" and is skipped from
the catalogue — layoutlib has no real Android context and there is no workaround. If your
CMP module uses composeResources, choose Roborazzi for it. Engines are per module, so a
CMP module on Roborazzi can sit next to an android module on Paparazzi.
The engines' golden files are not interchangeable — switching engines means re-recording
(the catalogue regenerates itself; retained goldens re-record on the next
generateShowcase run). Everything else — tasks, retention, verifyShowcase, the
catalogue — behaves identically on both.
- Every non-
private@Previewcomposable is collected automatically — including itsnameandgroup. That's the zero-effort starting point. - For components that stay in the catalogue, prefer
@ArkiveComposable: it carries what@Previewcan't (tags,skip, a FigmadesignNodeId,extraMetadata) and is validated with build errors, while a broken plain preview is silently skipped. @ArkiveViewdoes the same for XML layouts.
@ArkiveComposable(
name = "Primary Button",
group = "Buttons",
tags = ["cta"],
designNodeId = "123-456",
)
@Preview
@Composable
internal fun PrimaryButtonPreview() { ... }One gotcha worth knowing: a module with an empty test source set records nothing (KSP
skips compilations with no sources). Add a tiny internal object ArkivePlaceholder in
src/test/java — /arkive:setup does this for you.
The catalogue always receives every recorded snapshot. snapshotRetention controls what
stays in the src/test/snapshots golden directory afterwards:
NONE(default) — snapshots are consumed by the catalogue; nothing to verify.BASE— one golden per component stays. The recommended mode: a small golden set can live in the repo without committing every font/density/RTL variant.ALL— everything stays; consider Git LFS.
Record goldens with generateShowcase<Variant>, commit them, then verify in CI:
./gradlew verifyShowcase<Variant>
verifyShowcase fails the build with one aggregate report naming every mismatched
component, each with a delta image and an accept command. Missing goldens (new
components) fail too. With retention NONE it fails fast — there's nothing to verify.
When a change is intentional: re-run generateShowcase<Variant> and commit the updated
goldens.
Run it in its own Gradle invocation: the scoping narrows the module's shared
unit-test task to Arkive's generated class for the whole invocation, so it cannot be
combined with check, build, generateShowcase<Variant>, or anything else that runs
those tests — the build fails fast with an explanation if it is.
Arkive coexists with an existing snapshot-testing setup — but don't apply the engine's
plugin yourself alongside Arkive; Arkive applies the one the arkive.engine property
selects (an existing application is detected, just keep versions from conflicting):
- Your own tests, goldens, and
recordRoborazzi/verifyRoborazzi(orrecordPaparazzi/verifyPaparazzi) workflows keep working unchanged. Arkive only ever touches snapshot files recorded by its own generated test class. - For Arkive's snapshots, use
verifyShowcase<Variant>instead of the engine's verify task — it scopes the run to Arkive's test class and respects the retention policy. - Running the engine's plain verify task is still safe: Arkive's generated tests self-skip whatever the retention policy kept no goldens for.
- If you use Paparazzi for your own tests, note the engines don't mix in one module —
set
arkive.engine=paparazzithere.
Recording is deliberately resilient: a preview that fails to render is logged and skipped — it never breaks the build, and it's excluded from verification (it has no golden).
The repo is two Gradle builds: the root build holds the published modules and is
deliberately pinned to the oldest supported toolchain (its Kotlin is what makes the
annotations klibs readable by every Kotlin 2.0+ consumer — klibs are not
forward-compatible); the samples live in samples/, a standalone build on the newest
toolchain, simulating real consumers. The samples consume the published plugin, so
bootstrap mavenLocal first:
./gradlew publishToMavenLocal # in the repo root
cd samples && ./gradlew generateWebShowcase
Re-run the bootstrap whenever you change plugin code the samples should pick up.
Consuming a locally published build from another project additionally needs
mavenLocal() in the consumer's settings.gradle(.kts) — in both
pluginManagement.repositories (plugin marker + jar) and
dependencyResolutionManagement.repositories (the runtime artifacts the plugin injects).
We believe that the community can help us improve and build a better product. Please refer to our contributing guide to learn about the types of contributions we accept and the process for submitting them.
To ensure that our community remains respectful and professional, we defined a code of conduct that we expect all contributors to follow.
For reporting security vulnerabilities, please refer to our security policy.
We appreciate your interest and look forward to your contributions.
Copyright 2026 Infinum
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Maintained and sponsored by Infinum.