create-cmp-cli 0.5.0 → 0.6.0

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 CHANGED
@@ -101,7 +101,7 @@ Everything except `create` works on **any** KMP project, not just ones this tool
101
101
  Hit a KMP build error? [Common CMP/KMP build errors and fixes](docs/errors/README.md) — kotlin↔KSP
102
102
  mismatch, the KSP2/iOS catch-22, `SDK location not found`, `No space left on device`, version drift.
103
103
 
104
- ## The Claude Code plugin (8 skills)
104
+ ## The Claude Code plugin (9 skills)
105
105
 
106
106
  ```text
107
107
  /plugin marketplace add kvdm-co-pilot/create-cmp
@@ -115,13 +115,14 @@ Same engine as the CLI, conversational front door. Each skill is a guided flow,
115
115
  | `cmp-new` | "Make me an app." Interviews you in chat, scaffolds via the engine, generates your bottom-nav tab screens from the exemplar pattern, proves the build green. |
116
116
  | `cmp-doctor` | "Why won't my KMP project build?" Runs the doctor, explains the findings, applies consented fixes. |
117
117
  | `cmp-upgrade` | "Bump my dependencies safely." Diff → apply → verify, with the lockstep guardrails. |
118
+ | `cmp-preview` | "Show me my screens." Live gallery of every screen at a local URL — real DI/theme/data, no device, no emulator, no manual Gradle. Edit → save → the page re-renders itself; changed screens get flagged; a11y violations show per screen. |
118
119
  | `cmp-inspect` | "What did the UI actually render?" Reads a **running** app as structured JSON — hierarchy, geometry, resolved design tokens, navigation state. Never screenshots. Can assert tokens, find drift against your design system, audit accessibility, diff before/after. |
119
120
  | `cmp-dev-client` | "Let me iterate fast." Runs your shared UI in a phone-sized desktop window with hot reload — save a file, see it change. No emulator needed. Firebase stays off on desktop (offline fakes). |
120
121
  | `cmp-firebase-connect` | "Wire up my real Firebase." Drives the Firebase CLI: create/reuse a project, register the app, drop the real `google-services.json` over the placeholder, prove it with a green build. Every cloud action asks first. |
121
122
  | `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
122
123
  | `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
123
124
 
124
- Plus the **`cmp-inspector` MCP server** (14 tools) — the machine-readable window into a running
125
+ Plus the **`cmp-inspector` MCP server** (18 tools) — the machine-readable window into a running
125
126
  Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
126
127
  contract, three sources: render a screen headlessly, connect to the live app, or read a device
127
128
  via UIAutomator.
@@ -182,13 +183,21 @@ stamper (`qa/scaffold-feature.mjs`):
182
183
  Any plain Claude Code session — no create-cmp plugin installed — finds these and extends the app
183
184
  correctly by construction.
184
185
 
185
- ### 6. The live inspector — AI-readable UI
186
- Every debug build serves `127.0.0.1:9500` (loopback-only, structurally absent from release): the
187
- UI tree as JSON, the design-token catalog, a screenshot route, a tap route, and a live device
188
- view for humans (`/inspect/remote` — watch the real device in a browser, click to tap). Agents
189
- read structure; humans see pixels.
186
+ ### 6. The inspector — AI-readable UI, previews without a device
187
+ Two loops, one contract. **Live (tier 1):** every debug build serves `127.0.0.1:9500`
188
+ (loopback-only, structurally absent from release): the UI tree as JSON, the design-token catalog,
189
+ a screenshot route, a tap route, and a live device view for humans (`/inspect/remote` — watch the
190
+ real device in a browser, click to tap). **Headless previews (tier 0):** every app ships
191
+ `inspector/PreviewRegistry.kt` (the `@Preview` analog — shell, every tab, detail) and a
192
+ `:composeApp:renderScreens` task that renders each screen with real DI/theme/data to
193
+ `screen.png` + its contract `tree.json` — no device, no emulator; `node qa/preview-gallery.mjs`
194
+ turns the output into one self-contained `index.html` (pixels + wireframe + a11y per screen).
195
+ Agents read structure; humans see pixels.
190
196
 
191
197
  ### 7. The daily-driver extras
198
+ - **Live preview loop** — every real screen rendered headlessly on save (resident hot-reload
199
+ daemon, ~1s warm renders); a self-updating gallery for the human, changed-screen attribution
200
+ and compile-error surfacing for the agent. The agent sees what it builds.
192
201
  - **Desktop dev-client** — shared UI in a phone-sized JVM window, Compose Hot Reload attached.
193
202
  - **CI workflow** — Android job on every push; iOS job ready to un-comment.
194
203
  - **`CLAUDE.md`** — the AI delivery contract itself, stating everything above as rules any AI
@@ -201,8 +210,15 @@ read structure; humans see pixels.
201
210
  **New app → green.** `cmp-new` (or `npx create-cmp-cli`) → interview → stamp → green build proven
202
211
  → tab screens generated. Then `cmp-firebase-connect` to wire your real backend.
203
212
 
204
- **The daily UI loop.** `./gradlew :composeApp:hotRunDesktop --auto` edit Compose savesee
205
- it. No emulator, no Firebase, sub-second feedback.
213
+ **The daily UI loop.** Say "preview my app" (the cmp-preview skill / `preview` MCP tool) a
214
+ live local gallery of EVERY real screen that re-renders on save — no device, no emulator, no
215
+ manual Gradle. The agent runs the same loop to check its own work while it builds: edit →
216
+ `preview_status { waitForRender: true }` → which screens changed (or the compile error, or the
217
+ failed hot swap) → `preview_diff { screen }` for a proven verdict — feedback in seconds instead
218
+ of a 25–40s build or an emulator round-trip. One interactive window instead of stills:
219
+ `./gradlew :composeApp:hotRunDesktop --auto`. Command-line fallback (no plugin needed — the
220
+ scaffolded app carries the whole loop):
221
+ `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`.
206
222
 
207
223
  **The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
208
224
  edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
package/llms.txt CHANGED
@@ -14,7 +14,22 @@ Other flags: `--bundle-id`, `--region`, `--theme-prefix`, `--ios/--no-ios`, `--f
14
14
 
15
15
  Official alias packages (same tool, same flags, same maintainer): `npm create compose-multiplatform@latest my-app` and `npm create kmp@latest my-app` both delegate to create-cmp-cli.
16
16
 
17
- create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with eight skills and the `cmp-inspector` MCP server. Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
17
+ create-cmp is also invokable as a Claude Code plugin (`/plugin marketplace add kvdm-co-pilot/create-cmp`, then `/plugin install create-cmp`) with nine skills and the `cmp-inspector` MCP server (18 tools). Generated projects self-verify without the plugin installed: `node qa/verify.mjs` runs 8 gates (spec coverage, build, unit tests, conformance, golden trees, token drift, a11y, on-device E2E) and writes a content-hash-bound evidence receipt; a Stop hook and CI both refuse "done" without a fresh PASS receipt.
18
+
19
+ ## The UI feedback loop (for agents building UI)
20
+
21
+ Every generated app carries a device-free preview loop — no emulator, no manual Gradle. An
22
+ agent editing ANY screen should use it to see and verify its own work: with the plugin,
23
+ `preview { projectDir }` starts a resident service (live self-updating gallery URL for the
24
+ human), then after each edit `preview_status { waitForRender: true }` blocks until the
25
+ outcome — `changedLastRender` names the screens the edit touched (empty = it reached no
26
+ screen), `lastErrorSource: "compile"` means it didn't build — and `preview_diff { screen }`
27
+ returns a proven verdict (`proven-clean` / `changed-with-regressions` / `no-change`).
28
+ Warm renders take ~1s per screen (resident hot-reload daemon). Without the plugin:
29
+ `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`. Screens are registered
30
+ in `inspector/PreviewRegistry.kt`; the generated `CLAUDE.md`/`AGENTS.md` document the loop
31
+ in-repo ("UI feedback loop" section). Pixels flow to the human, structure (tree JSON) flows
32
+ to the agent — never read PNG bytes into model context.
18
33
 
19
34
  ## Docs
20
35
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.5.0",
4
- "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Installs the `create-cmp` command.",
3
+ "version": "0.6.0",
4
+ "description": "The AI delivery harness for Kotlin/Compose Multiplatform — a deterministic, non-interactive project generator that scaffolds a green-building app (Android + iOS) in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-cmp": "bin/create-cmp.mjs",
@@ -41,7 +41,12 @@
41
41
  "android",
42
42
  "ios",
43
43
  "verify",
44
- "e2e"
44
+ "e2e",
45
+ "preview",
46
+ "storybook",
47
+ "agent feedback loop",
48
+ "agents.md",
49
+ "claude"
45
50
  ],
46
51
  "license": "MIT",
47
52
  "author": "Karel van der Merwe",
package/src/lib/tabs.mjs CHANGED
@@ -317,6 +317,109 @@ fun PlaceholderScreen(title: String, titleTag: String) {
317
317
  `;
318
318
  }
319
319
 
320
+ /** Preview content lambda for one tab in the registry's appTabs(...) call — no-op navigation. */
321
+ function previewTabArg(tab) {
322
+ if (tab.slug === "home") {
323
+ return ` ${tab.param} = { HomeScreen(onItemClick = {}) },`;
324
+ }
325
+ if (tab.slug === "profile") {
326
+ return ` ${tab.param} = { ProfileScreen() },`;
327
+ }
328
+ return ` ${tab.param} = { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") },`;
329
+ }
330
+
331
+ /** Per-tab ScreenPreview entry (single tab hosted in TabHost, as AppShell hosts it). */
332
+ function previewEntry(tab) {
333
+ if (tab.slug === "home") {
334
+ return ` ScreenPreview("home", "${kotlinString(tab.label)} tab") { TabHost { HomeScreen(onItemClick = {}) } },`;
335
+ }
336
+ if (tab.slug === "profile") {
337
+ return ` ScreenPreview("profile", "${kotlinString(tab.label)} tab") { TabHost { ProfileScreen() } },`;
338
+ }
339
+ return ` ScreenPreview("${tab.slug}", "${kotlinString(tab.label)} tab") { TabHost { PlaceholderScreen(title = "${kotlinString(tab.label)}", titleTag = "${tab.slug}_title") } },`;
340
+ }
341
+
342
+ /**
343
+ * Render inspector/PreviewRegistry.kt (template-token form) — the `@Preview` analog the
344
+ * renderScreens harness enumerates: one shell entry wiring appTabs(...) like AppNavHost,
345
+ * one entry per tab, plus the shipped Detail destination. For the default tabs this
346
+ * reproduces the static template file byte-for-byte.
347
+ * @param {ReturnType<typeof tabInfos>} infos
348
+ */
349
+ export function renderPreviewRegistryKt(infos) {
350
+ const imports = [
351
+ "import androidx.compose.foundation.layout.Box",
352
+ "import androidx.compose.foundation.layout.fillMaxSize",
353
+ "import androidx.compose.runtime.Composable",
354
+ "import androidx.compose.ui.Modifier",
355
+ "import __PACKAGE__.presentation.components.BaseScreen",
356
+ ];
357
+ if (infos.some((t) => t.slug !== "home" && t.slug !== "profile")) {
358
+ imports.push("import __PACKAGE__.presentation.components.PlaceholderScreen");
359
+ }
360
+ imports.push("import __PACKAGE__.presentation.home.DetailScreen");
361
+ if (infos.some((t) => t.slug === "home")) {
362
+ imports.push("import __PACKAGE__.presentation.home.HomeScreen");
363
+ }
364
+ imports.push("import __PACKAGE__.presentation.navigation.AppShell");
365
+ imports.push("import __PACKAGE__.presentation.navigation.appTabs");
366
+ if (infos.some((t) => t.slug === "profile")) {
367
+ imports.push("import __PACKAGE__.presentation.profile.ProfileScreen");
368
+ }
369
+
370
+ return `package __PACKAGE__.inspector
371
+
372
+ ${imports.join("\n")}
373
+
374
+ /**
375
+ * One previewable screen: a stable [id] (the \`-Pscreen=\` selector and output directory
376
+ * name), a human [title] for the gallery, and the composable [content] exactly as the
377
+ * app hosts it.
378
+ *
379
+ * The \`@Preview\` analog for the create-cmp inspector: the registry makes "render screen
380
+ * X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
381
+ * configured \`tabs\`; when you add a screen by hand, add it here — the renderScreens
382
+ * harness, the gallery, and golden baselines pick it up by id.
383
+ *
384
+ * State variants (the Storybook "story" analog): a screen in a specific UI state is just
385
+ * another entry with a derived id — e.g. \`ScreenPreview("home@empty", "Home — empty")\`
386
+ * hosting the screen with that state forced (a state-first overload of the screen, or
387
+ * preview-only fakes behind its usual parameters). Every entry renders the same way
388
+ * (gallery card, \`-Pscreen=\` selector, golden baseline), so loading/empty/error states
389
+ * sit side by side with the default seeded state.
390
+ */
391
+ data class ScreenPreview(
392
+ val id: String,
393
+ val title: String,
394
+ val content: @Composable () -> Unit,
395
+ )
396
+
397
+ /** Every registered screen, in gallery order. Ids must be unique and filesystem-safe. */
398
+ fun previewRegistry(): List<ScreenPreview> = listOf(
399
+ ScreenPreview("shell", "App shell — bottom nav (first tab selected)") {
400
+ AppShell(
401
+ tabs = appTabs(
402
+ ${infos.map(previewTabArg).join("\n")}
403
+ ),
404
+ )
405
+ },
406
+ ${infos.map(previewEntry).join("\n")}
407
+ ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
408
+ )
409
+
410
+ /**
411
+ * Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
412
+ * bottom bar, so a tab previews with the same insets/background it gets in the shell.
413
+ */
414
+ @Composable
415
+ private fun TabHost(content: @Composable () -> Unit) {
416
+ BaseScreen {
417
+ Box(Modifier.fillMaxSize()) { content() }
418
+ }
419
+ }
420
+ `;
421
+ }
422
+
320
423
  // --- pipeline entry --------------------------------------------------------------
321
424
 
322
425
  const APPTAB_REL =
@@ -325,6 +428,8 @@ const NAVHOST_REL =
325
428
  "composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt";
326
429
  const PLACEHOLDER_REL =
327
430
  "composeApp/src/commonMain/kotlin/com/example/app/presentation/components/PlaceholderScreen.kt";
431
+ const PREVIEW_REGISTRY_REL =
432
+ "composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt";
328
433
  const SMOKE_REL = "qa/e2e/smoke.yaml";
329
434
 
330
435
  /**
@@ -362,6 +467,14 @@ export function rewriteTabSurfaces(projectDir, tabs, log = () => {}) {
362
467
  }
363
468
  }
364
469
 
470
+ // Present only when the inspector feature is on (the harness's desktopMain dir);
471
+ // absent → skipped, same contract as the other feature-stripped surfaces.
472
+ const previewRegistryPath = path.join(projectDir, PREVIEW_REGISTRY_REL);
473
+ if (fs.existsSync(previewRegistryPath)) {
474
+ fs.writeFileSync(previewRegistryPath, renderPreviewRegistryKt(infos));
475
+ log(` tabs → ${PREVIEW_REGISTRY_REL}`);
476
+ }
477
+
365
478
  const smokePath = path.join(projectDir, SMOKE_REL);
366
479
  if (fs.existsSync(smokePath)) {
367
480
  fs.writeFileSync(smokePath, renderSmokeYaml(infos));
@@ -0,0 +1,8 @@
1
+ # Agent instructions
2
+
3
+ This repository is agent-first. The full working contract — the definition of done
4
+ (`node qa/verify.mjs` must PASS), the architecture gates, the testing pyramid, and the
5
+ device-free **UI feedback loop** (render every real screen headlessly and see exactly
6
+ what your edit changed) — lives in [CLAUDE.md](./CLAUDE.md).
7
+
8
+ Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
@@ -52,6 +52,38 @@ invariants the conformance gates enforce.
52
52
  Commit it with your change — git history is the audit ledger. Binary artifacts under
53
53
  `qa-artifacts/` are hashed into the receipt; never commit them.
54
54
 
55
+ ## UI feedback loop — see what you build, without a device
56
+
57
+ <!-- >>> cmp:feature inspector -->
58
+ While building or changing ANY screen, use the preview loop instead of an emulator: it
59
+ renders this app's REAL screens (real DI, real theme, seeded data) headlessly in seconds
60
+ and tells you exactly what your edit changed.
61
+
62
+ **With the create-cmp plugin (cmp-inspector MCP tools):**
63
+
64
+ 1. `preview { projectDir }` — once per session. Returns a live gallery URL (give it to
65
+ the human; it re-renders itself on every save) plus per-screen structural summaries
66
+ for you. Sources are watched; you never run Gradle by hand.
67
+ 2. After each edit: `preview_status { waitForRender: true }` — blocks until the
68
+ render/compile outcome. `changedLastRender` names the screens your edit touched
69
+ (empty = the edit reached no screen); `lastErrorSource: "compile"` means the edit
70
+ didn't even build (the compiler's `e:` lines are in `lastError`).
71
+ 3. `preview_diff { screen }` — one call proves the change: verdict `proven-clean` /
72
+ `changed-with-regressions` / `no-change`. No snapshot bookkeeping needed.
73
+
74
+ **Without the plugin:** `./gradlew :composeApp:renderScreens` renders every screen to
75
+ `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
76
+ `node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
77
+
78
+ Screens come from `inspector/PreviewRegistry.kt` (desktopMain). **When you add a screen,
79
+ register it there** — a forced-state variant is just another entry (`"home@empty"`).
80
+ Assert on the `tree.json` structure; never read PNG bytes (pixels are for humans).
81
+ <!-- <<< cmp:feature inspector -->
82
+ <!-- >>> cmp:feature dev-client -->
83
+ For one interactive window instead of stills of every screen:
84
+ `./gradlew :composeApp:hotRunDesktop --auto` (Compose Hot Reload dev-client).
85
+ <!-- <<< cmp:feature dev-client -->
86
+
55
87
  ## Docs
56
88
 
57
89
  [`README.md`](./README.md) (front page) · [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
@@ -17,6 +17,11 @@ follow the contract in [`CLAUDE.md`](./CLAUDE.md).
17
17
  ./gradlew :composeApp:hotRunDesktop --auto
18
18
  <!-- <<< cmp:feature dev-client -->
19
19
 
20
+ <!-- >>> cmp:feature inspector -->
21
+ # Preview EVERY screen headlessly — no device, no emulator (agents: CLAUDE.md "UI feedback loop")
22
+ ./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs
23
+ <!-- <<< cmp:feature inspector -->
24
+
20
25
  # Unit tests (fast inner loop)
21
26
  ./gradlew :composeApp:desktopTest
22
27
 
@@ -136,6 +136,13 @@ kotlin {
136
136
  implementation(compose.desktop.currentOs)
137
137
  implementation(libs.kotlinx.coroutines.swing)
138
138
  implementation(libs.ktor.client.cio)
139
+ // >>> cmp:feature inspector
140
+ // Headless semantics API (runDesktopComposeUiTest / onRoot) for the preview
141
+ // harness (inspector/PreviewHarness.kt) — renders real screens with no window.
142
+ // Desktop is a dev-only target, so shipping the test artifact here is deliberate.
143
+ @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
144
+ implementation(compose.uiTest)
145
+ // <<< cmp:feature inspector
139
146
  }
140
147
  }
141
148
 
@@ -275,6 +282,52 @@ compose.desktop {
275
282
  }
276
283
  // <<< cmp:feature dev-client
277
284
 
285
+ // >>> cmp:feature inspector
286
+ // Headless screen previews — the project-wired tier-0 loop of the create-cmp inspector.
287
+ // Renders every screen in inspector/PreviewRegistry.kt (real DI, real theme, real data)
288
+ // to build/previews/<id>/{screen.png, tree.json} with NO device, emulator, or window,
289
+ // then qa/preview-gallery.mjs turns the output into one self-contained index.html:
290
+ //
291
+ // ./gradlew :composeApp:renderScreens # all screens
292
+ // ./gradlew :composeApp:renderScreens -Pscreen=home # one screen
293
+ // node qa/preview-gallery.mjs # build the gallery
294
+ //
295
+ // Parameters travel as -P properties -> system properties, NEVER via --args (Gradle's CLI
296
+ // parsing splits space-separated --args values into task names).
297
+ tasks.register<JavaExec>("renderScreens") {
298
+ group = "verification"
299
+ description = "Render registered screens headlessly to PNG + inspector-contract tree JSON."
300
+ val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
301
+ classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
302
+ mainClass.set("__PACKAGE__.inspector.PreviewHarnessKt")
303
+ systemProperty("java.awt.headless", "true")
304
+ systemProperty("screen", providers.gradleProperty("screen").getOrElse("all"))
305
+ systemProperty(
306
+ "out",
307
+ providers.gradleProperty("previewOut")
308
+ .getOrElse(layout.buildDirectory.dir("previews").get().asFile.absolutePath),
309
+ )
310
+ systemProperty("pngScale", providers.gradleProperty("pngScale").getOrElse("2"))
311
+ }
312
+
313
+ // Resident preview daemon (phase 2): a long-lived headless JVM serving /render on
314
+ // 127.0.0.1:9601 so warm re-renders skip the task cycle. Preferred launch is under
315
+ // Compose Hot Reload (saves hot-swap into the running daemon; needs the dev-client
316
+ // feature's Compose Hot Reload plugin):
317
+ //
318
+ // ./gradlew :composeApp:hotRunDesktop --mainClass=__PACKAGE__.inspector.PreviewDaemonKt --auto
319
+ //
320
+ // This plain variant runs on any JDK with no hot swap (restart to pick up new code):
321
+ tasks.register<JavaExec>("runPreviewDaemon") {
322
+ group = "verification"
323
+ description = "Run the resident preview daemon (loopback HTTP /render, no hot reload)."
324
+ val desktopCompilation = kotlin.targets.getByName("desktop").compilations.getByName("main")
325
+ classpath(desktopCompilation.output.allOutputs, desktopCompilation.runtimeDependencyFiles)
326
+ mainClass.set("__PACKAGE__.inspector.PreviewDaemonKt")
327
+ systemProperty("java.awt.headless", "true")
328
+ }
329
+ // <<< cmp:feature inspector
330
+
278
331
  // Evidence integrity: golden-tree baselines (qa/golden) and the UPDATE_GOLDEN capture flag are
279
332
  // REAL inputs of the JVM test tier, but Gradle can't see either on its own — baselines are read
280
333
  // at runtime, not compiled, and env vars aren't tracked. Undeclared, the build cache will happily
@@ -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
+ }