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 +25 -9
- package/llms.txt +16 -1
- package/package.json +8 -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
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 (
|
|
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** (
|
|
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
|
|
186
|
-
|
|
187
|
-
UI tree as JSON, the design-token catalog,
|
|
188
|
-
view for humans (`/inspect/remote` — watch the
|
|
189
|
-
|
|
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.**
|
|
205
|
-
|
|
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
|
|
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.
|
|
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.
|
package/template/CLAUDE.md
CHANGED
|
@@ -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)
|
package/template/README.md
CHANGED
|
@@ -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
|
+
}
|