Screenshot fixtures declared with @Preview

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

// 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

@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:

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