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.
- package/README.md +46 -10
- package/llms.txt +19 -1
- package/package.json +14 -3
- package/src/lib/tabs.mjs +113 -0
- package/template/AGENTS.md +8 -0
- package/template/CLAUDE.md +32 -0
- package/template/README.md +5 -0
- package/template/composeApp/build.gradle.kts +53 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +219 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +265 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +61 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +83 -0
- package/template/manifest.json +4 -2
- package/template/qa/lib/a11y.mjs +104 -0
- package/template/qa/lib/render.mjs +254 -0
- package/template/qa/lib/tree.mjs +108 -0
- package/template/qa/preview-gallery.mjs +107 -0
|
@@ -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
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt
ADDED
|
@@ -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
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt
ADDED
|
@@ -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
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt
ADDED
|
@@ -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
|
+
}
|
package/template/manifest.json
CHANGED
|
@@ -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
|
|
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,
|