create-cmp-cli 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,219 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import kotlinx.serialization.json.Json
4
+ import kotlinx.serialization.json.JsonElement
5
+ import kotlinx.serialization.json.JsonPrimitive
6
+ import kotlinx.serialization.json.buildJsonArray
7
+ import kotlinx.serialization.json.buildJsonObject
8
+ import com.sun.net.httpserver.HttpExchange
9
+ import com.sun.net.httpserver.HttpServer
10
+ import java.io.File
11
+ import java.net.InetAddress
12
+ import java.net.InetSocketAddress
13
+ import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.Executors
15
+ import java.util.concurrent.atomic.AtomicLong
16
+ import kotlin.system.exitProcess
17
+ import kotlin.system.measureTimeMillis
18
+
19
+ /**
20
+ * Resident preview daemon — phase 2 of the create-cmp preview loop (`@Preview` parity).
21
+ *
22
+ * A long-lived headless JVM that renders [previewRegistry] screens ON DEMAND over a
23
+ * loopback HTTP protocol, so the per-change cost drops from a full Gradle JavaExec cycle
24
+ * to a warm in-process render. Designed to run under **Compose Hot Reload**:
25
+ *
26
+ * ./gradlew :composeApp:hotRunDesktop --mainClass=__PACKAGE__.inspector.PreviewDaemonKt --auto
27
+ *
28
+ * With `--auto`, saving a source file recompiles incrementally and hot-swaps classes into
29
+ * THIS running JVM; every `/render` composes fresh scenes (and re-reads the registry), so
30
+ * the next render reflects the new code — typically 1–3s after save instead of a 20–40s
31
+ * task cycle. Also runnable without hot reload via the plain `runPreviewDaemon` task
32
+ * (resident, but a restart is needed to pick up recompiled classes).
33
+ *
34
+ * Routes (loopback only, mirroring the on-device inspector server's posture):
35
+ * GET /health → { ok, pid, screens, port, reloadCount, reloadHooked }
36
+ * GET /screens → the registry (ids + titles)
37
+ * GET /render?screen=<id|all>[&afterReload=<n>]
38
+ * → renders to the previews dir; → { rendered, ms, out, reloadCount, reloadHooked }.
39
+ * With afterReload, the render WAITS (≤10s) until reloadCount exceeds <n>: classes
40
+ * appearing on disk precede the in-JVM swap, so a caller that just saw a source
41
+ * change uses this to avoid composing pre-swap code (a stale render).
42
+ * GET /shutdown → 200, then exits
43
+ *
44
+ * Program args (never --args-through-Gradle for the renderScreens task, but ComposeHotRun
45
+ * passes them fine; all optional): `--port <n>` (default 9601), `--out <dir>` (default
46
+ * build/previews — resolved against the task's working dir, composeApp/), `--pngScale <n>`.
47
+ */
48
+ fun main(args: Array<String>) {
49
+ val port = argValue(args, "--port")?.toIntOrNull() ?: 9601
50
+ val outRoot = File(argValue(args, "--out") ?: "build/previews")
51
+ val pngScale = argValue(args, "--pngScale")?.toFloatOrNull()?.takeIf { it > 0f } ?: 2f
52
+
53
+ val reloadCount = AtomicLong(0)
54
+ val reloadErrors = AtomicLong(0)
55
+ val reloadHooked = hookReloadListener { succeeded ->
56
+ if (succeeded) reloadCount.incrementAndGet() else reloadErrors.incrementAndGet()
57
+ }
58
+
59
+ initPreviewKoin()
60
+ outRoot.mkdirs()
61
+ File(outRoot, "design-system.json").writeText(designSystemCatalog())
62
+
63
+ val renderLock = Any()
64
+ val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0)
65
+ server.executor = Executors.newFixedThreadPool(3)
66
+
67
+ server.createContext("/health") { exchange ->
68
+ respondJson(exchange, 200, buildJsonObject {
69
+ put("ok", JsonPrimitive(true))
70
+ put("pid", JsonPrimitive(ProcessHandle.current().pid()))
71
+ put("port", JsonPrimitive(port))
72
+ put("reloadCount", JsonPrimitive(reloadCount.get()))
73
+ put("reloadErrors", JsonPrimitive(reloadErrors.get()))
74
+ put("reloadHooked", JsonPrimitive(reloadHooked))
75
+ put("screens", buildJsonArray {
76
+ previewRegistry().forEach { add(JsonPrimitive(it.id)) }
77
+ })
78
+ })
79
+ }
80
+
81
+ server.createContext("/screens") { exchange ->
82
+ respondJson(exchange, 200, buildJsonObject {
83
+ put("screens", buildJsonArray {
84
+ previewRegistry().forEach {
85
+ add(buildJsonObject {
86
+ put("id", JsonPrimitive(it.id))
87
+ put("title", JsonPrimitive(it.title))
88
+ })
89
+ }
90
+ })
91
+ })
92
+ }
93
+
94
+ server.createContext("/render") { exchange ->
95
+ // Swap-aware rendering: classes on disk precede the in-JVM swap, so a caller
96
+ // that just observed a source change passes afterReload=<last seen count> and
97
+ // we wait (bounded) for the swap to actually land before composing.
98
+ val afterReload = queryParam(exchange, "afterReload")?.toLongOrNull()
99
+ if (afterReload != null && reloadHooked) {
100
+ val deadline = System.currentTimeMillis() + 10_000
101
+ while (reloadCount.get() <= afterReload && System.currentTimeMillis() < deadline) {
102
+ Thread.sleep(100)
103
+ }
104
+ }
105
+ // Re-read the registry PER REQUEST: after a hot swap, this picks up the
106
+ // redefined screen composables (fresh scenes are composed from current classes).
107
+ val all = previewRegistry()
108
+ val filter = queryParam(exchange, "screen") ?: "all"
109
+ val selected = if (filter == "all") all else all.filter { it.id == filter }
110
+ if (selected.isEmpty()) {
111
+ respondJson(exchange, 404, buildJsonObject {
112
+ put("error", JsonPrimitive(
113
+ "Unknown screen '$filter'. Available: ${all.joinToString(", ") { it.id }} (or 'all')."
114
+ ))
115
+ })
116
+ return@createContext
117
+ }
118
+ try {
119
+ val rendered = mutableListOf<String>()
120
+ val ms = measureTimeMillis {
121
+ synchronized(renderLock) {
122
+ for (entry in selected) {
123
+ val dir = File(outRoot, entry.id).apply { mkdirs() }
124
+ renderTree(entry, File(dir, "tree.json"))
125
+ renderPng(entry, File(dir, "screen.png"), pngScale)
126
+ rendered += entry.id
127
+ }
128
+ // The manifest always lists the FULL registry so single-screen renders
129
+ // keep the gallery complete.
130
+ File(outRoot, "manifest.json").writeText(manifestJson(all, pngScale))
131
+ }
132
+ }
133
+ respondJson(exchange, 200, buildJsonObject {
134
+ put("rendered", buildJsonArray { rendered.forEach { add(JsonPrimitive(it)) } })
135
+ put("ms", JsonPrimitive(ms))
136
+ put("out", JsonPrimitive(outRoot.absolutePath))
137
+ put("reloadCount", JsonPrimitive(reloadCount.get()))
138
+ put("reloadErrors", JsonPrimitive(reloadErrors.get()))
139
+ put("reloadHooked", JsonPrimitive(reloadHooked))
140
+ })
141
+ } catch (t: Throwable) {
142
+ respondJson(exchange, 500, buildJsonObject {
143
+ put("error", JsonPrimitive(t.message ?: t.toString()))
144
+ })
145
+ }
146
+ }
147
+
148
+ server.createContext("/shutdown") { exchange ->
149
+ respondJson(exchange, 200, buildJsonObject { put("ok", JsonPrimitive(true)) })
150
+ Thread {
151
+ Thread.sleep(100)
152
+ exitProcess(0)
153
+ }.start()
154
+ }
155
+
156
+ server.start()
157
+ System.err.println(
158
+ "preview daemon listening on http://127.0.0.1:$port " +
159
+ "(previews -> ${outRoot.absolutePath}, pngScale $pngScale)"
160
+ )
161
+ CountDownLatch(1).await() // resident until /shutdown or SIGTERM
162
+ }
163
+
164
+ /**
165
+ * Register a Compose Hot Reload after-reload callback via REFLECTION against the
166
+ * AGENT (`org.jetbrains.compose.reload.agent.ReloadHooksKt`) — the agent jar is what
167
+ * `hotRunDesktop` loads via -javaagent, so it IS visible to app code, unlike the
168
+ * runtime-api facade (verified: ClassNotFoundException). No compile-time dependency,
169
+ * so the inspector feature stays independent of the dev-client feature; plain
170
+ * `runPreviewDaemon` JVMs return false and just report reloadHooked=false.
171
+ *
172
+ * The callback receives Either<Reload, Throwable>: success bumps the reload count,
173
+ * failure (a swap the agent could not apply) is reported separately so callers can
174
+ * surface "your edit did not land" instead of rendering pre-swap code forever.
175
+ */
176
+ private fun hookReloadListener(onReload: (succeeded: Boolean) -> Unit): Boolean = try {
177
+ val hooks = Class.forName("org.jetbrains.compose.reload.agent.ReloadHooksKt")
178
+ val isSuccess = runCatching {
179
+ Class.forName("org.jetbrains.compose.reload.core.TryKt")
180
+ .getMethod("isSuccess", Class.forName("org.jetbrains.compose.reload.core.Either"))
181
+ }.getOrNull()
182
+ val callback: Function2<Any?, Any?, Unit> = { _, either ->
183
+ val ok = runCatching { isSuccess?.invoke(null, either) as? Boolean }.getOrNull() ?: true
184
+ onReload(ok)
185
+ }
186
+ hooks.getMethod("invokeAfterHotReload", Function2::class.java).invoke(null, callback)
187
+ true
188
+ } catch (t: Throwable) {
189
+ System.err.println(
190
+ "preview daemon: reload hook unavailable (${t.javaClass.simpleName}) — " +
191
+ "swap-aware renders disabled, callers fall back to time-based settling"
192
+ )
193
+ false
194
+ }
195
+
196
+ private fun argValue(args: Array<String>, flag: String): String? {
197
+ val i = args.indexOf(flag)
198
+ return if (i >= 0 && i + 1 < args.size) args[i + 1] else null
199
+ }
200
+
201
+ private fun queryParam(exchange: HttpExchange, key: String): String? =
202
+ exchange.requestURI.query
203
+ ?.split("&")
204
+ ?.mapNotNull { part ->
205
+ val eq = part.indexOf('=')
206
+ if (eq > 0) part.substring(0, eq) to part.substring(eq + 1) else null
207
+ }
208
+ ?.firstOrNull { it.first == key }
209
+ ?.second
210
+ ?.takeIf { it.isNotBlank() }
211
+
212
+ private val daemonJson = Json { prettyPrint = true }
213
+
214
+ private fun respondJson(exchange: HttpExchange, status: Int, body: JsonElement) {
215
+ val bytes = daemonJson.encodeToString(JsonElement.serializer(), body).toByteArray()
216
+ exchange.responseHeaders.set("Content-Type", "application/json")
217
+ exchange.sendResponseHeaders(status, bytes.size.toLong())
218
+ exchange.responseBody.use { it.write(bytes) }
219
+ }
@@ -0,0 +1,265 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.runtime.Composable
4
+ import androidx.compose.runtime.CompositionLocalProvider
5
+ import androidx.compose.runtime.remember
6
+ import androidx.compose.ui.ImageComposeScene
7
+ import androidx.compose.ui.graphics.toArgb
8
+ import androidx.compose.ui.test.ExperimentalTestApi
9
+ import androidx.compose.ui.test.onRoot
10
+ import androidx.compose.ui.test.runDesktopComposeUiTest
11
+ import androidx.compose.ui.unit.Density
12
+ import androidx.lifecycle.Lifecycle
13
+ import androidx.lifecycle.LifecycleOwner
14
+ import androidx.lifecycle.LifecycleRegistry
15
+ import androidx.lifecycle.ViewModelStore
16
+ import androidx.lifecycle.ViewModelStoreOwner
17
+ import androidx.lifecycle.compose.LocalLifecycleOwner
18
+ import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
19
+ import __PACKAGE__.core.connectivity.NetworkMonitor
20
+ // >>> cmp:feature room
21
+ import __PACKAGE__.data.local.AppDatabase
22
+ import __PACKAGE__.data.local.buildDatabase
23
+ // <<< cmp:feature room
24
+ import __PACKAGE__.di.appModules
25
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
26
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Theme
27
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
28
+ import kotlinx.serialization.json.Json
29
+ import kotlinx.serialization.json.JsonElement
30
+ import kotlinx.serialization.json.JsonPrimitive
31
+ import kotlinx.serialization.json.buildJsonArray
32
+ import kotlinx.serialization.json.buildJsonObject
33
+ import org.jetbrains.skia.EncodedImageFormat
34
+ import org.koin.core.context.startKoin
35
+ import org.koin.dsl.module
36
+ import java.io.File
37
+ import kotlin.system.exitProcess
38
+
39
+ // Phone-shaped viewport, density 1 — px == dp in the dumped tree (matches the dev-client
40
+ // window and keeps the inspector's a11y/touch-target math exact).
41
+ internal const val WIDTH = 411
42
+ internal const val HEIGHT = 891
43
+
44
+ /**
45
+ * Headless preview harness — the project-wired tier-0 loop of the create-cmp inspector.
46
+ *
47
+ * Renders REAL screens from [previewRegistry] with no device, emulator, or window:
48
+ * for each screen it writes the inspector-contract semantics tree (`tree.json`, via
49
+ * `runDesktopComposeUiTest`) and a pixel preview (`screen.png`, via [ImageComposeScene])
50
+ * from the same composition sources at the same viewport, plus the declared design-system
51
+ * catalog (`design-system.json`) and a `manifest.json` for gallery tooling
52
+ * (qa/preview-gallery.mjs turns the output into a single self-contained index.html).
53
+ *
54
+ * Real DI, real theme, real data: Koin starts with the same modules as the app, and
55
+ * screens resolve their ViewModels through `koinViewModel()` exactly as in production.
56
+ *
57
+ * Invoked by the `:composeApp:renderScreens` Gradle task; parameters arrive as SYSTEM
58
+ * PROPERTIES (never `--args`, which Gradle's CLI parsing mangles):
59
+ * -Pscreen=<id|all> which registry entry to render (default all)
60
+ * -PpreviewOut=<dir> output root (default build/previews)
61
+ * -PpngScale=<n> PNG density multiplier for sharpness (default 2; tree stays density 1)
62
+ */
63
+ fun main() {
64
+ val screenFilter = System.getProperty("screen")?.takeIf { it.isNotBlank() } ?: "all"
65
+ val outRoot = File(System.getProperty("out")?.takeIf { it.isNotBlank() } ?: "build/previews")
66
+ val pngScale = System.getProperty("pngScale")?.toFloatOrNull()?.takeIf { it > 0f } ?: 2f
67
+
68
+ initPreviewKoin()
69
+
70
+ val all = previewRegistry()
71
+ val selected = if (screenFilter == "all") all else all.filter { it.id == screenFilter }
72
+ if (selected.isEmpty()) {
73
+ System.err.println(
74
+ "Unknown screen '$screenFilter'. Available: ${all.joinToString(", ") { it.id }} (or 'all').",
75
+ )
76
+ exitProcess(2)
77
+ }
78
+
79
+ outRoot.mkdirs()
80
+ File(outRoot, "design-system.json").writeText(designSystemCatalog())
81
+
82
+ for (entry in selected) {
83
+ val dir = File(outRoot, entry.id).apply { mkdirs() }
84
+ renderTree(entry, File(dir, "tree.json"))
85
+ renderPng(entry, File(dir, "screen.png"), pngScale)
86
+ System.err.println("rendered ${entry.id} -> ${dir.absolutePath}")
87
+ }
88
+
89
+ File(outRoot, "manifest.json").writeText(manifestJson(selected, pngScale))
90
+ System.err.println("previews -> ${outRoot.absolutePath}")
91
+ // AWT/EDT and Koin threads are non-daemon; exit explicitly once outputs are on disk.
92
+ exitProcess(0)
93
+ }
94
+
95
+ /**
96
+ * Preview-harness DI — intentionally independent of the dev-client feature (which owns
97
+ * DesktopModule): the same platform bindings, started once for the render run.
98
+ */
99
+ internal fun initPreviewKoin() {
100
+ startKoin {
101
+ modules(
102
+ module {
103
+ // >>> cmp:feature room
104
+ single<AppDatabase> { buildDatabase() }
105
+ // <<< cmp:feature room
106
+ single { NetworkMonitor(null) }
107
+ },
108
+ *appModules.toTypedArray(),
109
+ )
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Provides what a bare offscreen composition lacks so production screens compose
115
+ * unmodified: a RESUMED [LifecycleOwner] (for `collectAsStateWithLifecycle`) and a fresh
116
+ * [ViewModelStoreOwner] per composition (so `koinViewModel()` resolves — and each render
117
+ * gets fresh ViewModels), then the app theme.
118
+ */
119
+ @Composable
120
+ internal fun PreviewRoot(content: @Composable () -> Unit) {
121
+ val owner = remember { PreviewOwner() }
122
+ CompositionLocalProvider(
123
+ LocalLifecycleOwner provides owner,
124
+ LocalViewModelStoreOwner provides owner,
125
+ ) {
126
+ __THEME_PREFIX__Theme(content)
127
+ }
128
+ }
129
+
130
+ internal class PreviewOwner : LifecycleOwner, ViewModelStoreOwner {
131
+ private val registry = LifecycleRegistry.createUnsafe(this).apply {
132
+ currentState = Lifecycle.State.RESUMED
133
+ }
134
+ override val lifecycle: Lifecycle get() = registry
135
+ override val viewModelStore = ViewModelStore()
136
+ }
137
+
138
+ /**
139
+ * Semantics tree at the phone viewport, density 1. ADAPTIVE settle: async data arrives in
140
+ * real time, so keep sampling until two consecutive dumps are identical (bounded) — static
141
+ * screens finish in ~300ms instead of paying the full window.
142
+ */
143
+ @OptIn(ExperimentalTestApi::class)
144
+ internal fun renderTree(entry: ScreenPreview, outFile: File) {
145
+ var json = ""
146
+ runDesktopComposeUiTest(width = WIDTH, height = HEIGHT) {
147
+ setContent { PreviewRoot { entry.content() } }
148
+ waitForIdle()
149
+ var prev: String? = null
150
+ var stable = 0
151
+ var iterations = 0
152
+ while (iterations < 8 && stable < 2) {
153
+ Thread.sleep(150)
154
+ waitForIdle()
155
+ val dump = PreviewSemanticsJson.dumpTree(onRoot(useUnmergedTree = true).fetchSemanticsNode())
156
+ if (dump == prev) stable++ else { stable = 0; prev = dump }
157
+ iterations++
158
+ }
159
+ json = prev ?: PreviewSemanticsJson.dumpTree(onRoot(useUnmergedTree = true).fetchSemanticsNode())
160
+ }
161
+ outFile.writeText(json)
162
+ }
163
+
164
+ /**
165
+ * Pixel twin of the tree: same content, same dp viewport, density [scale] for sharpness.
166
+ * Frames are re-rendered while invalidations arrive so async data reaches the pixels too.
167
+ */
168
+ internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
169
+ val scene = ImageComposeScene(
170
+ width = (WIDTH * scale).toInt(),
171
+ height = (HEIGHT * scale).toInt(),
172
+ density = Density(scale),
173
+ ) {
174
+ PreviewRoot { entry.content() }
175
+ }
176
+ try {
177
+ var elapsedNanos = 0L
178
+ var image = scene.render(elapsedNanos)
179
+ var quiet = 0
180
+ var iterations = 0
181
+ while (iterations < 12 && quiet < 2) {
182
+ Thread.sleep(100)
183
+ elapsedNanos += 100_000_000L
184
+ if (scene.hasInvalidations()) {
185
+ image = scene.render(elapsedNanos)
186
+ quiet = 0
187
+ } else {
188
+ quiet++
189
+ }
190
+ iterations++
191
+ }
192
+ val data = image.encodeToData(EncodedImageFormat.PNG)
193
+ ?: error("Skia failed to encode ${entry.id} as PNG")
194
+ outFile.writeBytes(data.bytes)
195
+ } finally {
196
+ scene.close()
197
+ }
198
+ }
199
+
200
+ /** The declared design-system catalog — generated FROM the theme objects, so it can't drift. */
201
+ internal fun designSystemCatalog(): String {
202
+ val pretty = Json { prettyPrint = true }
203
+ fun hex(color: androidx.compose.ui.graphics.Color): JsonElement =
204
+ JsonPrimitive("#%06X".format(color.toArgb() and 0xFFFFFF))
205
+ fun dp(value: androidx.compose.ui.unit.Dp): JsonElement =
206
+ JsonPrimitive("${value.value.toInt()}dp")
207
+
208
+ val doc = buildJsonObject {
209
+ put("colors", buildJsonObject {
210
+ put("Primary", hex(__THEME_PREFIX__Colors.Primary))
211
+ put("OnPrimary", hex(__THEME_PREFIX__Colors.OnPrimary))
212
+ put("Accent", hex(__THEME_PREFIX__Colors.Accent))
213
+ put("OnAccent", hex(__THEME_PREFIX__Colors.OnAccent))
214
+ put("Secondary", hex(__THEME_PREFIX__Colors.Secondary))
215
+ put("Error", hex(__THEME_PREFIX__Colors.Error))
216
+ put("Success", hex(__THEME_PREFIX__Colors.Success))
217
+ put("Warning", hex(__THEME_PREFIX__Colors.Warning))
218
+ put("Info", hex(__THEME_PREFIX__Colors.Info))
219
+ put("Background", hex(__THEME_PREFIX__Colors.Background))
220
+ put("Surface", hex(__THEME_PREFIX__Colors.Surface))
221
+ put("SurfaceVariant", hex(__THEME_PREFIX__Colors.SurfaceVariant))
222
+ put("OnSurface", hex(__THEME_PREFIX__Colors.OnSurface))
223
+ put("OnSurfaceVariant", hex(__THEME_PREFIX__Colors.OnSurfaceVariant))
224
+ put("Outline", hex(__THEME_PREFIX__Colors.Outline))
225
+ put("OutlineVariant", hex(__THEME_PREFIX__Colors.OutlineVariant))
226
+ put("Divider", hex(__THEME_PREFIX__Colors.Divider))
227
+ })
228
+ put("dimens", buildJsonObject {
229
+ put("ElevationCard", dp(__THEME_PREFIX__Tokens.ElevationCard))
230
+ put("ElevationModal", dp(__THEME_PREFIX__Tokens.ElevationModal))
231
+ put("PaddingPage", dp(__THEME_PREFIX__Tokens.PaddingPage))
232
+ put("PaddingCard", dp(__THEME_PREFIX__Tokens.PaddingCard))
233
+ put("GapCard", dp(__THEME_PREFIX__Tokens.GapCard))
234
+ put("BottomNavHeight", dp(__THEME_PREFIX__Tokens.BottomNavHeight))
235
+ put("RadiusCard", dp(__THEME_PREFIX__Tokens.RadiusCard))
236
+ put("RadiusPill", dp(__THEME_PREFIX__Tokens.RadiusPill))
237
+ put("RadiusModal", dp(__THEME_PREFIX__Tokens.RadiusModal))
238
+ put("RadiusInput", dp(__THEME_PREFIX__Tokens.RadiusInput))
239
+ })
240
+ }
241
+ return pretty.encodeToString(JsonElement.serializer(), doc)
242
+ }
243
+
244
+ internal fun manifestJson(entries: List<ScreenPreview>, pngScale: Float): String {
245
+ val pretty = Json { prettyPrint = true }
246
+ val doc = buildJsonObject {
247
+ put("viewport", buildJsonObject {
248
+ put("width", JsonPrimitive(WIDTH))
249
+ put("height", JsonPrimitive(HEIGHT))
250
+ put("treeDensity", JsonPrimitive(1))
251
+ put("pngScale", JsonPrimitive(pngScale))
252
+ })
253
+ put("screens", buildJsonArray {
254
+ entries.forEach { entry ->
255
+ add(buildJsonObject {
256
+ put("id", JsonPrimitive(entry.id))
257
+ put("title", JsonPrimitive(entry.title))
258
+ put("tree", JsonPrimitive("${entry.id}/tree.json"))
259
+ put("png", JsonPrimitive("${entry.id}/screen.png"))
260
+ })
261
+ }
262
+ })
263
+ }
264
+ return pretty.encodeToString(JsonElement.serializer(), doc)
265
+ }
@@ -0,0 +1,61 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.foundation.layout.Box
4
+ import androidx.compose.foundation.layout.fillMaxSize
5
+ import androidx.compose.runtime.Composable
6
+ import androidx.compose.ui.Modifier
7
+ import __PACKAGE__.presentation.components.BaseScreen
8
+ import __PACKAGE__.presentation.home.DetailScreen
9
+ import __PACKAGE__.presentation.home.HomeScreen
10
+ import __PACKAGE__.presentation.navigation.AppShell
11
+ import __PACKAGE__.presentation.navigation.appTabs
12
+ import __PACKAGE__.presentation.profile.ProfileScreen
13
+
14
+ /**
15
+ * One previewable screen: a stable [id] (the `-Pscreen=` selector and output directory
16
+ * name), a human [title] for the gallery, and the composable [content] exactly as the
17
+ * app hosts it.
18
+ *
19
+ * The `@Preview` analog for the create-cmp inspector: the registry makes "render screen
20
+ * X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
21
+ * configured `tabs`; when you add a screen by hand, add it here — the renderScreens
22
+ * harness, the gallery, and golden baselines pick it up by id.
23
+ *
24
+ * State variants (the Storybook "story" analog): a screen in a specific UI state is just
25
+ * another entry with a derived id — e.g. `ScreenPreview("home@empty", "Home — empty")`
26
+ * hosting the screen with that state forced (a state-first overload of the screen, or
27
+ * preview-only fakes behind its usual parameters). Every entry renders the same way
28
+ * (gallery card, `-Pscreen=` selector, golden baseline), so loading/empty/error states
29
+ * sit side by side with the default seeded state.
30
+ */
31
+ data class ScreenPreview(
32
+ val id: String,
33
+ val title: String,
34
+ val content: @Composable () -> Unit,
35
+ )
36
+
37
+ /** Every registered screen, in gallery order. Ids must be unique and filesystem-safe. */
38
+ fun previewRegistry(): List<ScreenPreview> = listOf(
39
+ ScreenPreview("shell", "App shell — bottom nav (first tab selected)") {
40
+ AppShell(
41
+ tabs = appTabs(
42
+ home = { HomeScreen(onItemClick = {}) },
43
+ profile = { ProfileScreen() },
44
+ ),
45
+ )
46
+ },
47
+ ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
48
+ ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
49
+ ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
50
+ )
51
+
52
+ /**
53
+ * Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
54
+ * bottom bar, so a tab previews with the same insets/background it gets in the shell.
55
+ */
56
+ @Composable
57
+ private fun TabHost(content: @Composable () -> Unit) {
58
+ BaseScreen {
59
+ Box(Modifier.fillMaxSize()) { content() }
60
+ }
61
+ }
@@ -0,0 +1,83 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.ui.semantics.SemanticsActions
4
+ import androidx.compose.ui.semantics.SemanticsNode
5
+ import androidx.compose.ui.semantics.SemanticsProperties
6
+ import androidx.compose.ui.semantics.getOrNull
7
+ import __PACKAGE__.presentation.theme.DesignTokenKey
8
+ import kotlinx.serialization.json.Json
9
+ import kotlinx.serialization.json.JsonElement
10
+ import kotlinx.serialization.json.JsonNull
11
+ import kotlinx.serialization.json.JsonObject
12
+ import kotlinx.serialization.json.JsonPrimitive
13
+ import kotlinx.serialization.json.buildJsonArray
14
+ import kotlinx.serialization.json.buildJsonObject
15
+ import kotlin.math.roundToInt
16
+
17
+ /**
18
+ * Walks a Compose [SemanticsNode] tree and serialises it to JSON matching the create-cmp
19
+ * inspector contract (schemaVersion 1, source "headless-jvm"). Every node carries pixel,
20
+ * root-relative `bounds` and a (possibly empty) `children` array; testTag / text /
21
+ * contentDescription / designToken are nullable; `role` / `clickable` / `disabled` are the
22
+ * additive interaction fields.
23
+ *
24
+ * This dumper reads THIS project's [DesignTokenKey], so the resolved design tokens the
25
+ * component kit self-reports (Modifier.designToken) appear in the dump — which is what
26
+ * makes the tree design-system-aware, not just geometry.
27
+ */
28
+ object PreviewSemanticsJson {
29
+
30
+ private val prettyJson = Json { prettyPrint = true }
31
+
32
+ fun dumpTree(root: SemanticsNode): String {
33
+ val doc = buildJsonObject {
34
+ put("schemaVersion", JsonPrimitive(1))
35
+ put("source", JsonPrimitive("headless-jvm"))
36
+ put("root", nodeToJson(root))
37
+ }
38
+ return prettyJson.encodeToString(JsonElement.serializer(), doc)
39
+ }
40
+
41
+ private fun nodeToJson(node: SemanticsNode): JsonObject = buildJsonObject {
42
+ put("testTag", node.config.getOrNull(SemanticsProperties.TestTag).toJson())
43
+ put(
44
+ "text",
45
+ node.config.getOrNull(SemanticsProperties.Text)
46
+ ?.joinToString(" ") { it.text }?.takeIf { it.isNotEmpty() }.toJson(),
47
+ )
48
+ put(
49
+ "contentDescription",
50
+ node.config.getOrNull(SemanticsProperties.ContentDescription)
51
+ ?.joinToString(" ")?.takeIf { it.isNotEmpty() }.toJson(),
52
+ )
53
+ put("role", node.config.getOrNull(SemanticsProperties.Role)?.toString().toJson())
54
+ put("clickable", JsonPrimitive(node.config.contains(SemanticsActions.OnClick)))
55
+ put("disabled", JsonPrimitive(node.config.contains(SemanticsProperties.Disabled)))
56
+ put("bounds", node.boundsJson())
57
+ put("designToken", node.designTokenJson())
58
+ put("children", buildJsonArray { node.children.forEach { add(nodeToJson(it)) } })
59
+ }
60
+
61
+ private fun SemanticsNode.boundsJson(): JsonObject {
62
+ val rect = boundsInRoot
63
+ return buildJsonObject {
64
+ put("x", JsonPrimitive(rect.left.roundToInt()))
65
+ put("y", JsonPrimitive(rect.top.roundToInt()))
66
+ put("width", JsonPrimitive(rect.width.roundToInt()))
67
+ put("height", JsonPrimitive(rect.height.roundToInt()))
68
+ }
69
+ }
70
+
71
+ private fun SemanticsNode.designTokenJson(): JsonElement {
72
+ val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
73
+ return buildJsonObject {
74
+ put("tokens", buildJsonArray { info.tokens.forEach { add(JsonPrimitive(it)) } })
75
+ put("resolved", buildJsonObject {
76
+ info.resolved.forEach { (k, v) -> put(k, JsonPrimitive(v)) }
77
+ })
78
+ }
79
+ }
80
+
81
+ private fun String?.toJson(): JsonElement =
82
+ if (this == null) JsonNull else JsonPrimitive(this)
83
+ }
@@ -94,9 +94,11 @@
94
94
  "enabledByDefault": true,
95
95
  "paths": [
96
96
  "composeApp/src/androidDebug/kotlin/com/example/app/inspector",
97
- "composeApp/src/androidRelease/kotlin/com/example/app/inspector"
97
+ "composeApp/src/androidRelease/kotlin/com/example/app/inspector",
98
+ "composeApp/src/desktopMain/kotlin/com/example/app/inspector",
99
+ "qa/preview-gallery.mjs"
98
100
  ],
99
- "notes": "Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. When off: delete both inspector dirs AND strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call) and composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission). Zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain); no build-file markers needed."
101
+ "notes": "Two loops in one feature. (1) Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. (2) Headless preview harness (tier 0): the desktopMain inspector dir (PreviewRegistry/PreviewHarness/PreviewSemanticsJson) + the :composeApp:renderScreens task render every registered screen to PNG + contract tree JSON with no device; qa/preview-gallery.mjs builds a self-contained index.html from the output (vendored pure-logic render libs live in qa/lib and stay when the feature is off — they are inert without the harness). The harness starts its OWN Koin (independent of the dev-client feature's DesktopModule) and its PreviewRegistry tab entries are regenerated from the configured `tabs` by the scaffolder (pipeline step b.3). When off: delete all three inspector dirs + qa/preview-gallery.mjs, strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call), composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission), and composeApp/build.gradle.kts (desktopMain compose.uiTest dep + the renderScreens task). On-device server: zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain)."
100
102
  },
101
103
  "dev-client": {
102
104
  "enabledByDefault": true,