# Screenshot fixtures declared with @Preview

> Since 0.3.0 viddik reads androidx.compose.ui.tooling.preview.Preview, so one annotation serves the IDE preview pane, Android's screenshot tooling and the golden-file suite at once.

Page: https://kotlin.website/blog/screenshot-fixtures-from-preview · Published: 2026-08-27

[viddik](https://github.com/youndie/viddik) renders Compose Multiplatform components through a
real Compose Desktop window and diffs them against committed goldens. Until 0.3.0 a fixture
stated its name and size in viddik's own vocabulary, on `@ViddikScreenshot`. It now reads them
off `@Preview` instead.

That is worth doing because of where `@Preview` lives. Compose Multiplatform 1.12 ships
`androidx.compose.ui.tooling.preview.Preview` in `commonMain`, under the same fully-qualified
name Android uses. So a single annotation is read by the IDE preview pane, by Android's own
`com.android.compose.screenshot` plugin, and by viddik — with nothing in between translating
between three vocabularies.

## Why read `@Preview`

A component worth a golden usually already has a preview, written so somebody could look at it
while building it. Before 0.3.0 that meant two declarations of the same two facts — what the
component is called and how big it should be drawn — and a third if an Android module also ran
Google's screenshot plugin. Three places to change, and nothing that notices when they disagree.

`@ViddikScreenshot` stays, but only as the opt-in marker. Bare `@Preview` is deliberately not
scanned: in an application of any size most previews exist for the IDE and cannot render
headless at all — a `hiltViewModel()` here, a `LocalContext` read there — so capturing every one
of them produces a suite that fails for reasons that have nothing to do with the UI. Android's
own tool reached the same conclusion and requires `@PreviewTest` alongside `@Preview`.

## What is read

| `@Preview` field | effect |
|---|---|
| `name`, `group` | golden file name and the group in the component browser |
| `widthDp`, `heightDp` | capture size in pixels — viddik renders at density 1 |
| `uiMode` | the night bits make this fixture render dark |
| `fontScale` | scales text inside the capture, not the canvas |
| `device` | the size out of a `spec:` string, and nothing else |
| repeated `@Preview`, multipreview | one fixture per preview, resolved recursively |
| `@PreviewWrapper` | wraps the fixture before it is captured |

Precedence per field is the argument on `@ViddikScreenshot`, then the `@Preview` field, then
viddik's default — so a fixture that spells everything the old way resolves exactly as it did.

`uiMode` and the older `darkVariant` answer different questions: `uiMode` says *this* fixture is
dark, `darkVariant` asks for a second, dark entry beside a light one. Setting both is a compile
error. `uiMode` is a bit field rather than a value — `@PreviewLightDark` carries 33 on its dark
half, `UI_MODE_NIGHT_YES` combined with `UI_MODE_TYPE_NORMAL` — so it is masked, not compared.

## Installation

```kotlin
// settings.gradle.kts
pluginManagement { repositories { maven("https://reposilite.kotlin.website/snapshots") } }

// build.gradle.kts of the module holding the fixtures
plugins {
    id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
    id("ru.workinprogress.viddik") version "0.3.0.15"
}
```

The plugin adds the artefacts, puts the processor on the right `ksp*` configuration for your
module's shape, and registers `viddikRecord` / `viddikVerify` / `viddikShowroom`.

0.3.x requires Compose Multiplatform 1.12.x. viddik reaches past the public test API into
`ComposeScene` and Skiko, so it is bound to one Compose line rather than a range, and a mismatch
surfaces at runtime on the first captured frame rather than at compile time.

## How it looks

```kotlin
@ViddikScreenshot
@PreviewWrapper(AppPreviewTheme::class)
@PreviewLightDark
@Composable
fun PrimaryButton() {
    Button(onClick = {}) { Text("Continue") }
}
```

Two goldens, `PrimaryButton - Light` and `PrimaryButton - Dark`, both drawn inside
`AppPreviewTheme`. The same declaration is what the IDE shows in its preview pane.

## The theme a fixture used to have to remember

`@PreviewWrapper` is the part that changes how a suite is written, and the reason is mechanical.

A theme cannot be forced onto a composable from outside its composition. The capture engine
renders whatever `content()` it is handed, and if that content calls its own `MaterialTheme`,
that call wins over anything provided further out. Goldens are only portable across machines
when text is drawn in a bundled font rather than the host's, so every fixture had to remember to
call the harness that supplies one — and a fixture that forgot did not fail. It recorded a
golden drawn in whatever font the recording machine happened to have, which then failed on
somebody else's.

`@PreviewWrapper` names a `PreviewWrapperProvider`, and viddik applies it during code
generation — outside the composition, where a theme can still be imposed:

```kotlin
class AppPreviewTheme : PreviewWrapperProvider {
    @Composable
    override fun Wrap(content: @Composable () -> Unit) {
        MaterialTheme(typography = viddikTypography(), content = content)
    }
}
```

Because the annotation may also sit on an annotation class, a project's own `@AppPreviews` can
carry the theme and the light/dark pair together, and fixtures declare neither.

viddik's own suite is the check on this: it contains a fixture with no theme call of its own,
and that fixture verifies against one committed golden on Linux, macOS and Windows in CI. It
could only do that if the wrapper really did supply the bundled font — the three platforms use
three different font backends, and a host font would diverge immediately.

## Limitations

`device` is read for its size only, and only in the `spec:width=…dp,height=…dp` form. `dpi`,
`orientation` and `isRound` are a density or a device shape that a flat canvas has no equivalent
for, and a named device such as `id:pixel_5` keeps its dimensions in Android's catalogue rather
than in the annotation. Both are warnings rather than errors: a fixture carrying `device` for
the IDE's sake is still a good fixture, it just does not get that device.

`fontScale` scales text and not the canvas, because the capture path treats a dp and a pixel as
the same unit; scaling the density would resize every golden a `@PreviewFontScale` produces.
`darkVariant` alongside several previews is refused outright — it doubles every fixture a
function makes, so one `@PreviewFontScale` would quietly become fourteen goldens.

Only the common `androidx.compose.ui.tooling.preview.Preview` is read; the older desktop-only
`androidx.compose.desktop.ui.tooling.preview.Preview` is not, because it cannot serve Android.
An Android consumer of `viddik-annotations` needs `compileSdk = 37`, which is what Compose
Multiplatform 1.12 requires of everything depending on it.

Finally, sharing an annotation is not sharing a renderer. Point Google's screenshot plugin at
the same `@Preview` functions and it renders them through LayoutLib while viddik renders them
through Skiko: two sets of goldens that will never agree pixel for pixel, and are not meant to
be compared.

---

Source: [github.com/youndie/viddik](https://github.com/youndie/viddik)
