Repository navigation
Releases: youndie/viddik
Release list
v0.7.0 — edits that skip KSP, suites past a thousand fixtures, a floor that sees small changes
On Maven Central as io.github.youndie.viddik, the same coordinates as 0.6.x:
plugins {
id("io.github.youndie.viddik") version "0.7.0"
}Editing a component no longer re-runs KSP (#50, #51, #54). The Compose compiler records each composable's source offsets in @FunctionKeyMeta, so a body-only edit changed the class file of every composable after it, and KSP reprocessed every fixture file for a registry that came out the same. The test source set's KSP run now reads a declarations-only snapshot — bodies, debug information and those offsets taken out — of the module's main classes and, through an artifact transform, of the other modules of the build it depends on. On a module of 1000 fixtures, editing a component and recording one golden went from ~4.1 s to ~3.3 s; with the fixtures in a module of their own and the components in a sibling module, from ~6 s to ~3.3 s. A changed declaration still re-runs KSP. Works with the configuration cache and isolated projects. viddik { kspDeclarationSnapshot = false } turns it off.
Suites past a thousand fixtures compile (#49). The generated registry was one initializer, and 2000 fixtures put it over the JVM's 64 KB method limit (Method too large). It is now split into chunk files of 200; GeneratedViddikRegistry.components keeps its shape and order, so sharding is unchanged.
The pixel floor only absorbs faint pixels (#47). A comparison with at most 16 mismatched pixels used to pass whatever those pixels were; a full stop appended to a heading (12–13 px, channel delta 223) got through even at zero tolerance. Now a pixel past a channel delta of 96 is never absorbed by the floor; the cross-OS residue it exists for measures 13 px at delta 47. The percentage share still counts every pixel alike, so on a full screen at the default 0.05% a change that small still passes; set tolerancePercent = 0.0 to catch it — Linux goldens still verify on macOS with that. The extension gains minMismatchedPixels and floorChannelDelta (255 restores the count-only floor).
Compatibility: no rendering change, so goldens recorded on 0.6.x hold. A verify that passed on 0.6.1 can fail on 0.7.0 when a golden is stale by a few pronounced pixels — that is the floor change working, and the reason for a minor bump. No API was removed.
v0.6.1 — the showroom source directory only with showroom targets on
On Maven Central as io.github.youndie.viddik, the same coordinates as 0.6.0:
plugins {
id("io.github.youndie.viddik") version "0.6.1"
}One fix, and the reason for the release:
- The Gradle plugin adds the showroom source directory to
commonMainonly when showroom targets are on (#44, #45). WithshowroomTargetsleft at its default, a Kotlin Multiplatform module that applies KSP stopped./gradlew buildwith Gradle's implicit-dependency error, once for every per-target KSP task (kspKotlinDesktop,kspKotlinWasmJs,kspKotlinIos*,kspAndroidMain). The directory was registered unconditionally, but the task ordering that makes it safe to read was wired only for showroom targets. Consumers had to remove the directory fromcommonMainthemselves or resolve a wip snapshot.
Nothing else changed since 0.6.0. It stays source- and binary-compatible, and goldens recorded on 0.6.0 hold.
v0.6.0 — recording that leaves goldens alone, and a suite four times faster
On Maven Central as io.github.youndie.viddik, the same fifteen coordinates as 0.5.0:
plugins {
id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
id("io.github.youndie.viddik") version "0.6.0"
}Requires Compose Multiplatform 1.12.x, unchanged. Source-compatible with 0.5.x for everything a
fixture touches; DiffResult is no longer a data class (see below), and two settings now default
differently — read "Defaults that changed" before upgrading a large module.
Measured end to end on a downstream suite of 748 fixtures (8-core laptop, medians of interleaved
runs): a verification took 104 s on 0.5.0 and about 25 s on 0.6.0, and recording on an unchanged
tree rewrote 644 of 748 goldens before and 0 after.
Recording writes only what a verification would reject (#29)
viddikRecord used to write every fixture it rendered, so a record on an unchanged tree produced a
diff made of files the verification had just accepted — review noise, and a fresh blob per file
where goldens live in Git LFS. It now compares first, through the same differ and the same
thresholds viddikVerify resolves, and says what it did:
viddik record: nothing to write — all 746 golden(s) already pass the verification.
--force (or -Dviddik.forceRecord=true) rewrites everything, for the re-record after a Compose,
font or renderer bump.
Faster, in three independent places
- Auto-height captures render into a surface of the measured height (#31). They used to draw,
encode and decode the full 4000 px canvas and then crop it away — 1.6 Mpx round-tripped to keep
0.02. Capture time on this repository's own suite: 1353 ms → 764 ms, all goldens byte-identical. - The differ reads pixels in bulk and paints the diff image only when something differs (#33).
126 comparisons of 400x460 goldens: 936 ms → 515 ms. - One scene serves the whole run (#40), reopened per canvas size. 13.3 ms → 5.7 ms per capture
on a suite of same-sized fixtures.
Defaults that changed
sceneReuseis on, andshardsis 2 — both on the plugin's extension, so they apply to
viddikVerify/viddikRecord, which run the generated screenshot tests and nothing else.
captureComposableandViddikEngine.verifycalled from your own test are unaffected.- Turn them down on a small module: a fork costs ~1.9 s of JVM and Compose start-up against ~7 ms
per capture, and four forks were slower than one on a 20-core Linux box while two were worth 2x
on an 8-core laptop.viddik { shards = 1 },sceneReuse = false. - Set
sceneReuse = falseif your verification task also runs Compose tests of your own: two
harnesses cannot share a JVM, and the run fails with "the shared capture scene did not answer". - The fixtures are visited in size order under scene reuse, and sharding emits
GeneratedViddikTests0,GeneratedViddikTests1, … instead of oneGeneratedViddikTests. Both
still match the*GeneratedViddikTests*filter; a script naming the class exactly needs updating.
generateTests = false now actually applies
The plugin passed its KSP options through a CommandLineArgumentProvider, which declares no inputs,
so the KSP task's cache key did not contain them. With org.gradle.caching=true Gradle restored an
output generated under the previous values — a documented setting that silently did nothing, with
the result depending on cache history. If you set generateTests = false on 0.4.x or 0.5.x and
wondered why the test class was still there, this is why, and upgrading will change what your build
produces.
Also
- A capture can refuse text the font cannot draw (#41), opt-in:
viddik { glyphCheck = true },
withglyphCheckFontfor a module that bundles its own. A glyph the font lacks is drawn by the
host, so the golden is stable where it was recorded and different elsewhere — and the pixels that
move are the ones after the character. The bundled Roboto covers‹ « < × … •and none of
← → ↑ ↓ ✕ ▸. DiffResultis a normal class rather than adata class: it carries the diff image lazily.
diffImage,mismatchedPixels,totalPixels,mismatchPercentandmatches()read as before;
copy(), destructuring andequalsare gone.ImageDifferTestandViddikSceneReuseTestpin the comparison contract and the reused scene;
CI records this repository's own suite through both capture paths on ubuntu, macOS and Windows and
compares them.
v0.5.0 — the showroom on Android and iOS, and design parity
On Maven Central as io.github.youndie.viddik, fifteen coordinates including the plugin marker and
the iOS variants:
plugins {
id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
id("io.github.youndie.viddik") version "0.5.0"
}Requires Compose Multiplatform 1.12.x, unchanged from 0.2.x. Source-compatible with 0.4.x — the third
parameter on ViddikShowroom is not binary-compatible, so recompile rather than swap the jar.
Two things landed: the component browser left the desktop window, and fixtures can be measured against
the designs they were built to.
📱 The showroom on Android and iOS
A component library is judged on a phone, and until now the browser could only be opened on the
machine that ran the build — KSP generates the registry into the module's test source set, and a
test source set is never compiled into an app.
viddik {
showroomTargets = true
}That moves the processor to kspCommonMainMetadata and the generated directory to commonMain, so
the registry is compiled for every target the module has. The actual migration is moving the
fixtures to commonMain; the rest is wiring the plugin does. Goldens are untouched: the JUnit 5
class that drives them is JVM-only and cannot be common, so the plugin writes it into the test source
set itself, over the registry commonMain produced. viddikVerify, viddikRecord and --component
work exactly as before.
Then the new viddik-showroom module hosts it. On Android, a subclass and a manifest entry:
class ShowroomActivity : ViddikShowroomActivity() {
override val components = GeneratedViddikRegistry.components
}On iOS, a view controller:
// iosMain
fun ShowroomViewController(): UIViewController =
ViddikShowroomUIViewController(GeneratedViddikRegistry.components)Both are ViddikShowroomApp underneath — the browser inside a default MaterialTheme, with Android's
back gesture wired to close a component's detail view rather than the activity. Host ViddikShowroomApp,
or ViddikShowroom itself which imposes no theme, if you would rather put it inside your own navigation.
The registry is passed in, not looked up: a fixture that stopped compiling is then a build error
rather than an empty list at launch, and Kotlin/Native has no reflective lookup to offer anyway.
viddik-annotations gained iosArm64 and iosSimulatorArm64. There is no iosX64 — Compose
Multiplatform stopped publishing the Intel-simulator variant, and asking for it fails resolution of
every compose artifact in commonMain rather than of that one target.
🔎 Search
The list has a search field over it. The query is split on whitespace and every token has to appear in
"$group $name", case-insensitively — so wid but finds Widgets / Button, and word order does not
matter. The field says how many components survived the query and clears with the × beside it.
State is hoisted into ViddikShowroomState, which is what lets the Android host own the back gesture
and what lets a screenshot fixture photograph a mid-search screen.
🎨 Design parity — viddikDesignParity
A golden answers "did the rendering change". While a screen is being built the question that comes
first is how far it is from the design it was built to, and golden thresholds cannot answer it: the
reference was drawn by another rasterizer, so 0.05% at ±2 per channel fails on anti-aliasing alone.
Put a PNG of each design state next to the goldens, under design/, named exactly like the golden the
fixture records:
src/desktopTest/snapshots/
├── Checkout_Empty.png # golden, recorded by viddikRecord
└── design/
└── Checkout_Empty.png # reference, exported from the design — never written by viddik
./gradlew :yourModule:viddikDesignParity # every fixture
./gradlew :yourModule:viddikDesignParity --component "Checkout*" # same filter as verify
./gradlew :yourModule:viddikDesignParity -Pviddik.designStrict # fail on a mismatchIt reports rather than judges by default — it passes, and prints one line per fixture:
Design parity: 1/3 within 5.0% (channel tolerance ±16), 6 without a reference
ok Buttons - Filled 0.00%
no ref Buttons - Filled Dark (expected src/desktopTest/snapshots/design/Buttons_Filled_Dark.png)
DIFF Buttons - Outlined 16.40% -> build/reports/screenshots/design/Buttons_Outlined_DIFF.png
A fixture without a reference is information, not a failure — a module rarely has a design for every
state of every component. The one thing that does fail a report-mode run is no reference matching
any fixture at all, which is a wrong designDir or a naming slip, and a green run that measured
nothing would hide it. designStrict = true (or -Pviddik.designStrict) turns each mismatch into a
failing test as well.
Everything measured lands in build/reports/screenshots/design/: every fixture's render as
<name>_ACTUAL.png to put beside the reference, a red-mask <name>_DIFF.png wherever pixels
differed, summary.txt, and summary.json with per-fixture status, pixel counts, percentage, both
sizes and both paths. The previous run's files are removed first, so a diff that no longer reproduces
never sits next to a fresh summary.
The defaults — 5% of pixels, ±16 per channel — are a starting point for "looks the same when drawn by
two rasterizers", not a measurement; calibrate them on a screen you consider done, then tighten. A
fixture's own tolerancePercent is deliberately not consulted here: it budgets rendering noise
between two runs of the same code, which is a different question. And neither this task nor
viddikRecord ever writes into design/ — a run that could overwrite the reference with the render
would turn "does the code match the design" into "does the code match itself".
🧪 samples/ — all of the above, running
A separate Gradle build in this repository that consumes viddik through includeBuild(".."), the way
any other project would: a fixtures module with its fixtures in commonMain, an Android app, and an
iOS app that needs no Xcode project at all — samples/scripts/ios-showroom.sh links the executable,
wraps it in a bundle and launches the simulator.
Full Changelog: v0.4.0...v0.5.0
v0.4.0 — on Maven Central as io.github.youndie.viddik
The first release on Maven Central, and the reason it is a 0.4.0 rather than a 0.3.4: the
group, the plugin id and the Kotlin packages all moved to io.github.youndie.viddik.
// settings.gradle.kts — mavenCentral() in BOTH blocks, google() beside the second
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}plugins {
id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
id("io.github.youndie.viddik") version "0.4.0"
}Requires Compose Multiplatform 1.12.x, unchanged from 0.2.x.
⚠️ Breaking — the coordinate
| 0.3.x and earlier | 0.4.0 | |
|---|---|---|
| repository | https://reposilite.kotlin.website/snapshots |
mavenCentral() |
| group | ru.workinprogress |
io.github.youndie.viddik |
| plugin id | ru.workinprogress.viddik |
io.github.youndie.viddik |
| packages | ru.workinprogress.viddik.* |
io.github.youndie.viddik.* |
A consumer changes its coordinates, its plugins { id(...) } line and its imports. Nothing else
about viddik moved. Everything up to 0.3.3 stays where it is and will not disappear; nothing new is
published under the old name.
ru.workinprogress cannot go to Central at all: the portal verifies a domain namespace by a DNS
record on the domain itself, and workinprogress.ru is a parked lot belonging to somebody selling it.
io.github.<login> is proved by owning the GitHub account. Changing the group alone would not have
been enough either — a Gradle plugin marker takes its groupId from the plugin id, so a dry run put
it at ru.workinprogress.viddik:ru.workinprogress.viddik.gradle.plugin with the libraries already
renamed. Once a consumer is editing its plugins block anyway, leaving the packages behind would only
have spent the same break twice.
Both settings blocks, and google(). The plugin marker resolves out of pluginManagement, which
does not look at Central unless told to; and the desktop Compose variants pull androidx.compose.runtime
and androidx.lifecycle.*, which are not on Central. Without google() the build fails with
Could not find androidx.compose.runtime:runtime — an error that names the artifact, not the missing
repository.
🫧 Text under a blur or glass is portable now
The one hole left in cross-platform goldens. Skia lifts the perspective term out of the matrix before
rasterizing the contents of a filtering layer — filters do not work in perspective — and that term is
the entire mechanism by which viddik takes glyphs away from the host font backend. Inside
Modifier.blur, a graphicsLayer(renderEffect = ...) or any glass effect, text went back to being
drawn by CoreText / DirectWrite / FreeType.
Measured macOS → Linux: text under blur(2.dp) differed in 1.40% of pixels, the same text with no
effect in 0.00%, geometry under the same blur in 0.00%. A wide blur smears the difference
until it hides under the channel tolerance, which is why the same fixture could be green on one OS and
red on another.
The fix is Modifier.viddikStableGlyphs() — in viddik-annotations, so it is callable from
production code — placed inside the effect, on the content being blurred, not around it. Outside
the capture it is one composition-local read and the receiver unchanged.
Nine ways a subtree can escape the scene matrix were measured; three are holes (an image filter, a
runtime-shader RenderEffect, a layer read back with toImageBitmap()), and Dialog, Popup,
CompositingStrategy.Offscreen, a shadow with a non-rectangular clip and a plainly recorded layer all
keep the term. Each one now has a canary that fails with the mechanism named the moment it changes
sides.
🩹 Fixes
viddikStableGlyphs()appended a copy of the whole chain, not a node —then(glyphPerspectiveNudge())
on an implicit receiver that is the chain itself. A blur in that chain was applied twice, and a
layerBackdroprecorded its subtree twice, which killed the JVM inside Skia's record optimizer
(#11). The node also readsLocalViddikCaptureat draw time now, and concatenates the term in and
back out instead of wrapping it in save/restore.- A popup is captured instead of being demanded to be a dialog. A second semantics root means a
Dialog or a Popup, and the engine asked for a dialog node in both cases — so any fixture opening a
dropdown menu or a tooltip failed withExpected exactly '1' node ... (IsDialog is defined), naming
the one thing it does not have. A popup lives inside the window, so the whole scene is kept. - The published POM no longer carries the publishing machine's skiko.
viddik-testing-corehad
api(compose.desktop.currentOs)in a published source set, which resolves to whoever ran the build
—desktop-jvm-macos-arm64— and handed it to every consumer at compile scope. It is
compose.desktop.commonnow. Nobody noticed for four releases because a consumer following the
setup adds the right runtime beside the wrong one; on Central a POM can never be rewritten, which is
what made it worth fixing before the first release rather than after.
🎯 A fixture can state its own tolerance
@ViddikScreenshot(tolerancePercent = ...) overrides the run's threshold for one fixture, so a
rendering path that provably cannot hold the strict number stops forcing the whole module to be
loosened. Resolution is fixture → viddik { tolerancePercent } → the default, with an explicit
ViddikEngine.verify argument still winning over all three. The processor emits the argument only
when it was stated, so a module built against older annotations keeps compiling.
🧱 Build
The build conventions moved out of buildSrc into the shared youndie/sborka
plugins, which is also where the Central release workflow lives: the portal token and the signing key
exist in one repository instead of one copy per library.
Full Changelog: v0.3.0...v0.4.0
v0.3.0 — fixtures declared with @Preview
Published to https://reposilite.kotlin.website/snapshots as 0.3.0.15 — the base version plus
the build number, which is the coordinate to depend on:
plugins {
id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
id("ru.workinprogress.viddik") version "0.3.0.15"
}Requires Compose Multiplatform 1.12.x, unchanged from 0.2.x.
A fixture no longer has to state its name and size in viddik's own vocabulary. @ViddikScreenshot
works as a bare marker, with the details read off @Preview:
@ViddikScreenshot
@PreviewWrapper(AppPreviewTheme::class)
@PreviewLightDark
@Composable
fun PrimaryButton() { ... } // two goldens, themed by the wrapperNothing existing changes. A fixture that spells everything on @ViddikScreenshot resolves exactly as
it did, and the Compose Multiplatform line is unchanged from 0.2.x.
🔗 Why @Preview
Compose Multiplatform 1.12 ships androidx.compose.ui.tooling.preview.Preview in common, and it
is the same fully-qualified name Android uses. So one declaration is read by three different things:
the IDE preview pane, Android's own screenshot tooling, and viddik. No bridge code, and a fixture
states its name and size once instead of three times.
The legacy androidx.compose.desktop.ui.tooling.preview.Preview is deliberately not read — it can't
serve Android, which is the point of the exercise.
📖 What is read
name, group |
golden name and showroom group |
widthDp, heightDp |
capture size in pixels — viddik renders at density 1 |
uiMode |
night bits → this fixture renders dark |
fontScale |
scales text, not the canvas |
device |
size only, spec: form only — the rest warns |
@PreviewWrapper |
wraps the fixture, applied at codegen |
| repeated / multipreview | one fixture each, resolved recursively |
Precedence per field: an argument on @ViddikScreenshot, then the @Preview field, then viddik's
default.
🎨 @PreviewWrapper — the theme, declared once
class AppPreviewTheme : PreviewWrapperProvider {
@Composable
override fun Wrap(content: @Composable () -> Unit) {
MaterialTheme(typography = viddikTypography(), content = content)
}
}This is the part worth upgrading for. A theme can't be forced onto a composable from inside the
composition — which is why, until now, every fixture had to remember to call the harness that gives it
the bundled font, and a fixture that forgot silently recorded a golden drawn in the host's system
font. That is exactly the thing that isn't portable across machines.
@PreviewWrapper is applied at codegen time, outside the composition, so the harness lives in one
place. And because the annotation can sit on an annotation class, a project's own @AppPreviews can
carry the theme and the light/dark pair together.
The release's own test suite is the evidence: Demo_Wrapped is a fixture with no theme call of its
own, and it verifies on CoreText, DirectWrite and FreeType alike. It could only do that if the wrapper
really did supply the bundled font.
🧩 Multipreview
@Preview is repeatable, and a multipreview annotation is just an annotation class carrying several —
so one marker gives one fixture per preview, @PreviewLightDark and hand-rolled ones alike, including
multipreviews built out of multipreviews.
With several previews the @ViddikScreenshot name becomes the stem and each preview says which one it
is; an unnamed preview falls back to its index, so names can't collapse into each other.
🚧 Deliberate refusals
- Bare
@Previewis not scanned.@ViddikScreenshotstays the opt-in — capturing every preview in
a codebase would turn IDE-only previews into goldens, most of which can't render headless. Google's
own tool reached the same conclusion with its separate@PreviewTestmarker. uiModeis notdarkVariant. One says this fixture is dark; the other asks for a second
entry beside a light one. Both at once is an error, not a dark golden with an identical dark golden
next to it.darkVariantalongside several previews is refused — it doubles every fixture, so one
@PreviewFontScalewould quietly become fourteen goldens.- Two distinct
@PreviewWrappers on one fixture is an error. devicewarns rather than fails.dpi,orientationandisRoundare a density or a device
shape a flat canvas has no equivalent for, and a named device keeps its dimensions in Android's
catalogue rather than in the annotation. A fixture carryingdevicefor the IDE's sake is still a
good fixture; it just doesn't get that device.
🔍 One that would have been silent
@PreviewLightDark's dark half carries uiMode = 33, not 32 — UI_MODE_NIGHT_YES or
UI_MODE_TYPE_NORMAL. Checking it for equality against the constant would have read it as light and
recorded two identical goldens with different names. It is read as the bit field it is, and there's a
test pinning that exact value.
✅ Under the hood
viddik-processor gained a test source set — it had none. Fixture resolution is pure and now lives
outside KSP, pinned by 24 unit tests rather than only end-to-end. ViddikDensityTest pins that the
harness renders at density 1 (so a @Preview dp is a pixel) and that a font scale moves text without
moving the canvas.
Suite 24 → 30 tests, golden fixtures 12 → 17. Every golden verifies on Linux, macOS and Windows.
v0.2.0 — Compose Multiplatform 1.12 & the Gradle plugin
Published to https://reposilite.kotlin.website/snapshots as 0.2.0.14 — the base version plus the
build number, which is the coordinate to actually depend on:
plugins {
id("com.google.devtools.ksp") version "<your Kotlin compiler version>"
id("ru.workinprogress.viddik") version "0.2.0.14"
}Two things landed since v0.1.2: the Gradle plugin that replaces the hand-copied wiring, and the move
onto Compose Multiplatform 1.12.
⚠️ Breaking
- Requires Compose Multiplatform 1.12.x.
viddik-testing-corerenders throughComposeSceneand
skikodirectly, so it is bound to one CMP line rather than a range — and a mismatch surfaces at
runtime on the first captured frame (NoSuchMethodError/IllegalAccessError), after a clean
compile. README now carries a compatibility table for that reason. Stay on0.1.xfor CMP 1.11. - An Android consumer of
viddik-annotationsneedscompileSdk = 37, which is what CMP 1.12
requires of everything depending on it.
🔌 The Gradle plugin
id("ru.workinprogress.viddik") replaces the block every consumer used to copy by hand. It adds the
artifacts at the right coordinates, puts the processor on the right ksp* configuration, registers the
generated-source directory, and gives you viddikRecord / viddikVerify / viddikShowroom.
The wiring it replaces was spelled differently in every consumer, because the names depend on module
shape: jvm("desktop") needs kspDesktopTest and src/desktopTest/snapshots, an unnamed jvm() needs
kspJvmTest, and a plain kotlin("jvm") module needs kspTest plus the platform-suffixed artifacts.
Getting one wrong never errored — KSP reported SKIPPED and the screenshot task passed with no tests
in it. ViddikLayout now makes that choice, and it is the part with unit tests.
Configuration lives in a viddik { } block: snapshotsDir, tolerancePercent, channelTolerance,
reportsDir, verifyOnCheck, generateTests, excludeFromTestTask, addDependencies,
viddikVersion.
🎯 Selecting one fixture
viddikRecord/viddikVerify --component "<pattern>" narrows a run to one fixture — a case-insensitive
substring of "$group - $name", with */? wildcards. Gradle's own --tests cannot do this: the
fixtures are JUnit5 dynamic tests under a single generated class, and --tests matches classes and
methods only. A pattern matching nothing throws and lists what does exist, so an over-narrow filter
can't masquerade as a passing run.
🖼️ Goldens
Unchanged by the CMP move. The PNGs recorded against 1.11 verify against 1.12 with no re-record, on
Linux, macOS and Windows alike. To rule out a vacuous pass, one golden was deliberately corrupted
(2.08% of pixels) and that fixture alone went red.
📦 Dependencies
| from | to | |
|---|---|---|
| Compose Multiplatform | 1.11.1 | 1.12.0 |
| compose-material3 | 1.11.0-alpha07 | 1.12.0-alpha03 |
| Android Gradle Plugin | 9.3.1 | 9.3.2 |
| Gradle | 9.7.0 | 9.7.1 |
Kotlin (2.4.10), KSP (2.3.11), kotlinpoet, JUnit, kotlinx-coroutines, Dokka and ktlint were already at
their latest stable versions.
🔧 Porting notes
The 1.11 → 1.12 move was two independent breakages in CaptureEngine:
ComposeScene.render(canvas, nanoTime)is gone, split intomeasureAndLayout()+draw(canvas).- skiko 0.150.1 turned
org.jetbrains.skia.Matrix44into a value class whose array constructor is no
longer public, soMatrix44(*floatArrayOf(...))stopped compiling.
If you hit either of these in your own Skiko-level code: pinning skiko back is not a fix. It is not
declared anywhere — it arrives transitively with compose.ui and its version is chosen by CMP — and
holding it back only gets past the first error into the second.
v0.1.2
v0.1.1 — first release
First tagged release of viddik — a screenshot-testing toolkit for Compose Multiplatform that renders through a real Compose Desktop/Skiko JVM window instead of Android/LayoutLib. One @ViddikScreenshot annotation gives you a golden-file test and an entry in an interactive component browser, on a plain JVM: no emulator, no AVD, no LayoutLib.
Goldens are portable across operating systems
The headline of this release. A golden recorded on macOS verifies on Linux CI and vice versa, so recording no longer has to happen on the machine that checks it. Every PR re-runs the committed PNGs on ubuntu-latest, macos-latest (arm64) and windows-latest at once.
Two independent causes had to be fixed at the source:
- Vertical font metrics. Font backends read them from different tables of the same file — FreeType and CoreText take
hhea, DirectWrite takesOS/2.usWin*. In Roboto those disagree (ascent −12.988281 vs −13.302734 at 14px), so line height and baseline differed per OS and every line after the first drifted a pixel.normalizeVerticalMetrics()forces all three sources to agree; it is public, for projects bundling their own font. - Glyph rasterization. Handled by the host font backend, which no
FontRasterizationSettingscombination reconciles.CaptureEnginehands the canvas a matrix with a 1e-9 perspective term — Skia's own condition for filling glyph outlines with its regular path rasterizer instead of the platform glyph cache. Geometry moves by ~1e-6 px; rendering stops depending on the OS.
Anti-aliasing is on, which reads backwards and is the measured result: on the path rasterizer an aliased mask turns every sub-pixel disagreement into a full 0↔255 pixel flip, while anti-aliased coverage comes out bit-identical.
Measured Windows vs Linux across 8 fixtures: 1.93%–27.4% mismatch with host fonts, 0.27%–5.65% with the previous "no anti-aliasing" setup, 0.000% on 6 of 8 fixtures now — byte-identical PNGs. On a downstream consumer's suite, 26 of 28 goldens failed cross-platform before and 31 of 32 tests pass after.
Also in this release
ViddikGlyphCoverage.missingGlyphs(text, fontBytes)— reads a font'scmapand names the codepoints that would be drawn by a host font (a✕used as a close button, CJK, emoji). That is the one part of text rendering that can't be made portable from outside Compose; this makes it fail loudly instead of silently differing per machine.DEFAULT_TOLERANCE_PERCENTis 0.05 with a 16-pixel floor, so small fixtures aren't judged by percentage alone. For scale: adding one character to a button label moves 1.32% of the pixels.- Auto-height fixtures,
@PreviewParametersupport withViddikPreviewLabelnaming, dark variants, andViddikShowroomas a live browser over the same registry the tests read.
Install
repositories {
maven("https://reposilite.kotlin.website/snapshots")
}
dependencies {
testImplementation("ru.workinprogress:viddik-annotations:0.1.1")
testImplementation("ru.workinprogress:viddik-testing-core:0.1.1")
add("kspDesktopTest", "ru.workinprogress:viddik-processor:0.1.1")
}A plain kotlin("jvm") consumer needs the platform-suffixed artifacts instead (viddik-annotations-desktop, viddik-testing-core-jvm). The attached zip is the same set of artifacts in Maven repository layout, for anyone who wants to drop them into a local mirror.
Breaking vs. pre-release code: ViddikConsistentRendering is gone. Deterministic rasterization is now unconditional, so the only remaining choice is whether to use the bundled font — a call to viddikTypography().