create-cmp-cli 0.9.0 → 0.10.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 +16 -0
- package/package.json +1 -1
- package/src/lib/tabs.mjs +6 -0
- package/template/CLAUDE.md +24 -6
- package/template/README.md +9 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
- package/template/docs/ARCHITECTURE.md +55 -0
- package/template/docs/TESTING.md +7 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +44 -28
- package/template/qa/verify.mjs +63 -6
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +5 -0
package/README.md
CHANGED
|
@@ -64,6 +64,22 @@ accessibility, on-device E2E — and writes a receipt bound to a content hash of
|
|
|
64
64
|
verified. The Stop hook and CI both check that receipt. You cannot hand-forge it, and a stale one
|
|
65
65
|
doesn't pass.
|
|
66
66
|
|
|
67
|
+
## Watch and drive your app live — from a browser
|
|
68
|
+
|
|
69
|
+
Every debug build carries a live device view. With the app running on a device or emulator
|
|
70
|
+
(`adb forward tcp:9500 tcp:9500`, or just `connect_live` from the plugin), open:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
http://127.0.0.1:9500/inspect/remote
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A self-contained page mirrors the running app (~700ms refresh) and **click-to-tap drives the
|
|
77
|
+
real thing** — clicks scale to device pixels and dispatch as taps. This is the "two audiences,
|
|
78
|
+
one app" split at its purest: the human watches and drives real pixels in a browser while the
|
|
79
|
+
agent asserts on the semantics tree (`navigate_and_inspect`, `prove_change`, `db_query`). Use
|
|
80
|
+
it to watch an e2e run, demo a feature, or poke at the app without touching the device. No
|
|
81
|
+
install, no CORS, debug builds only — release builds contain none of this code.
|
|
82
|
+
|
|
67
83
|
## Quick start
|
|
68
84
|
|
|
69
85
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app 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": {
|
package/src/lib/tabs.mjs
CHANGED
|
@@ -166,6 +166,12 @@ export function renderSmokeYaml(infos) {
|
|
|
166
166
|
# AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
|
|
167
167
|
# keep these ids in sync with it if the configured tabs change.
|
|
168
168
|
#
|
|
169
|
+
# SETTLE RULE for the behaviour steps you add: any assertion that follows an interaction
|
|
170
|
+
# triggering an async state change (search input, a toggle that persists, a load) must use
|
|
171
|
+
# extendedWaitUntil — the ViewModel round-trip passes through a brief Loading arm, and a
|
|
172
|
+
# lane-loaded emulator stretches that window past a bare assert's patience (a real false
|
|
173
|
+
# red from the field). Bare assertVisible is for static post-navigation elements only.
|
|
174
|
+
#
|
|
169
175
|
# Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
|
|
170
176
|
# The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
|
|
171
177
|
appId: __PACKAGE__
|
package/template/CLAUDE.md
CHANGED
|
@@ -101,22 +101,33 @@ human decision. The ordered walk is a **definition order**, not just an approval
|
|
|
101
101
|
each artifact is the vocabulary the next is written in, so on a fresh app each step is a
|
|
102
102
|
conversation that ends in an approval — the genesis walk, six conversations:
|
|
103
103
|
|
|
104
|
+
The order encodes two disciplines: **behavior is spec-first** (the exemplar's clauses are
|
|
105
|
+
confirmed before the slice is built) and **visuals are UI-first** (the design system and
|
|
106
|
+
component vocabulary are distilled from the real screens, so they lock after the exemplar —
|
|
107
|
+
a provisional palette carries the build until then).
|
|
108
|
+
|
|
104
109
|
0. **Intent** — `specs/intent.md`, the root brief everything else traces to (purpose,
|
|
105
110
|
audience, platforms, brand feel, reference apps, first screens, **glossary**). Filled by
|
|
106
111
|
the `cmp-new` interview; the seed's placeholder prose is marked unfilled. Its
|
|
107
112
|
`## Glossary` section is lifted verbatim into `docs/ARCHITECTURE.md` §8 — write it there
|
|
108
113
|
in the exact form you want published.
|
|
109
|
-
1. **
|
|
110
|
-
2. **Architecture + structure** — `specs/app-base.spec.md` **and**
|
|
114
|
+
1. **Architecture + structure** — `specs/app-base.spec.md` **and**
|
|
111
115
|
[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) (`cmp:generated` sections stripped
|
|
112
116
|
before hashing, so a mechanical regeneration never invalidates the approval — only an
|
|
113
117
|
authored-prose edit does).
|
|
114
|
-
|
|
118
|
+
2. **Exemplar spec** — `specs/<exemplar>.spec.md`. Confirmed BEFORE the slice is built:
|
|
119
|
+
propose the clauses, get the human's yes, then implement to satisfy them (the same
|
|
120
|
+
discipline `add-feature` already enforces post-genesis).
|
|
121
|
+
3. **Exemplar feature** — the **configured** exemplar's file set the `add-feature`
|
|
122
|
+
stamper clones from (see "Configurable exemplar"), built to the confirmed spec.
|
|
123
|
+
4. **Design system** — `presentation/theme/Theme.kt`, `presentation/theme/Tokens.kt`.
|
|
124
|
+
Locked on the REAL exemplar: candidates render on real screens, never stubs. If the
|
|
125
|
+
lock changes the exemplar's look, reopen → re-approve it — that loop is the design,
|
|
126
|
+
not a failure.
|
|
127
|
+
5. **Components** — every `presentation/components/*.kt` (a dynamic, sorted glob),
|
|
128
|
+
distilled from the screens per the inclusion rubric (`docs/ARCHITECTURE.md` §7). Once
|
|
115
129
|
approved, the registry is law: adding or changing a common component invalidates the
|
|
116
130
|
approval until a human re-approves.
|
|
117
|
-
4. **Exemplar feature** — the **configured** exemplar's 11-file set the `add-feature`
|
|
118
|
-
stamper clones from (see "Configurable exemplar").
|
|
119
|
-
5. **Exemplar spec** — `specs/<exemplar>.spec.md`.
|
|
120
131
|
6. **Per-feature spec** — `specs/<feature>.spec.md`, one governed artifact per feature,
|
|
121
132
|
added as features land.
|
|
122
133
|
|
|
@@ -220,6 +231,13 @@ and tells you what your edit changed.
|
|
|
220
231
|
`composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
|
|
221
232
|
`node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
|
|
222
233
|
|
|
234
|
+
**Live tier — the human's live device view (standing step).** Whenever `connect_live`
|
|
235
|
+
succeeds, OFFER the `remoteUrl` it returns (`http://127.0.0.1:9500/inspect/remote`) to the
|
|
236
|
+
human — every time, not as a maybe. It is a self-contained browser page that mirrors the
|
|
237
|
+
running app (~700ms refresh) with click-to-tap driving the real device: they watch and drive
|
|
238
|
+
the actual app while you assert on the tree (`navigate_and_inspect` / `prove_change` /
|
|
239
|
+
`db_query`). It is also the right way for a human to *watch* an e2e run.
|
|
240
|
+
|
|
223
241
|
Screens come from `inspector/PreviewRegistry.kt` (desktopMain). The `add-feature` and
|
|
224
242
|
`add-screen` stampers auto-register stamped screens at the `// cmp:anchor preview-registry`
|
|
225
243
|
marker; when you add a screen by hand, register it there — a forced-state variant is just
|
package/template/README.md
CHANGED
|
@@ -29,6 +29,15 @@ follow the contract in [`CLAUDE.md`](./CLAUDE.md).
|
|
|
29
29
|
node qa/verify.mjs
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
<!-- >>> cmp:feature inspector -->
|
|
33
|
+
**Watch and drive the app live from a browser:** with a debug build running on a
|
|
34
|
+
device/emulator, run `adb forward tcp:9500 tcp:9500` and open
|
|
35
|
+
`http://127.0.0.1:9500/inspect/remote` — a self-contained page that mirrors the running app
|
|
36
|
+
(~700ms refresh) with **click-to-tap driving the real device**. The human's window into the
|
|
37
|
+
same app the agent inspects structurally; also the way to watch an e2e run. Debug builds
|
|
38
|
+
only — release builds contain no inspector code.
|
|
39
|
+
<!-- <<< cmp:feature inspector -->
|
|
40
|
+
|
|
32
41
|
<!-- >>> cmp:feature ios -->
|
|
33
42
|
**iOS:** `cd iosApp && xcodegen generate && pod install`, then open
|
|
34
43
|
`iosApp.xcworkspace` in Xcode and run. (First time: `brew install xcodegen`.)
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt
CHANGED
|
@@ -5,9 +5,12 @@ import androidx.compose.ui.graphics.toArgb
|
|
|
5
5
|
import androidx.compose.ui.unit.Dp
|
|
6
6
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
7
7
|
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
8
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__TypeRamp
|
|
8
9
|
import kotlinx.serialization.json.Json
|
|
9
10
|
import kotlinx.serialization.json.JsonElement
|
|
11
|
+
import kotlinx.serialization.json.JsonNull
|
|
10
12
|
import kotlinx.serialization.json.JsonPrimitive
|
|
13
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
11
14
|
import kotlinx.serialization.json.buildJsonObject
|
|
12
15
|
import kotlinx.serialization.json.put
|
|
13
16
|
|
|
@@ -57,6 +60,22 @@ object InspectorCatalog {
|
|
|
57
60
|
put("RadiusModal", __THEME_PREFIX__Tokens.RadiusModal.token())
|
|
58
61
|
put("RadiusInput", __THEME_PREFIX__Tokens.RadiusInput.token())
|
|
59
62
|
})
|
|
63
|
+
// The type ramp, from the same ramp data the Typography factory builds
|
|
64
|
+
// its styles from — so the LIVE tier answers with the same block the
|
|
65
|
+
// headless preview catalog writes, and the console renders one ramp
|
|
66
|
+
// whichever tier it read.
|
|
67
|
+
put("typography", buildJsonArray {
|
|
68
|
+
__THEME_PREFIX__TypeRamp.forEach { spec ->
|
|
69
|
+
add(buildJsonObject {
|
|
70
|
+
put("name", spec.name)
|
|
71
|
+
put("weight", spec.weight)
|
|
72
|
+
put("size", "${spec.sizeSp}sp")
|
|
73
|
+
put("lineHeight", "${spec.lineHeightSp}sp")
|
|
74
|
+
val tracking = spec.tracking
|
|
75
|
+
if (tracking == null) put("tracking", JsonNull) else put("tracking", "${tracking}sp")
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
})
|
|
60
79
|
}
|
|
61
80
|
return prettyJson.encodeToString(JsonElement.serializer(), doc)
|
|
62
81
|
}
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
package __PACKAGE__.inspector
|
|
2
2
|
|
|
3
|
+
import android.app.Activity
|
|
3
4
|
import android.content.Context
|
|
5
|
+
import android.content.ContextWrapper
|
|
4
6
|
import android.graphics.Bitmap
|
|
5
7
|
import android.graphics.Canvas
|
|
8
|
+
import android.graphics.Rect
|
|
9
|
+
import android.os.Build
|
|
6
10
|
import android.os.Handler
|
|
7
11
|
import android.os.Looper
|
|
8
12
|
import android.os.SystemClock
|
|
9
13
|
import android.util.Log
|
|
10
14
|
import android.view.MotionEvent
|
|
15
|
+
import android.view.PixelCopy
|
|
16
|
+
import android.view.View
|
|
17
|
+
import android.view.Window
|
|
11
18
|
import androidx.core.view.drawToBitmap
|
|
12
19
|
import java.io.BufferedReader
|
|
13
20
|
import java.io.ByteArrayOutputStream
|
|
@@ -149,6 +156,11 @@ object InspectorHttpServer {
|
|
|
149
156
|
tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
|
|
150
157
|
method == "GET" && path == "/inspect/nav" ->
|
|
151
158
|
writeJson(client, 200, navJson())
|
|
159
|
+
// The JUMP half of the nav seam (the read half is /inspect/nav above): coverage
|
|
160
|
+
// by route, not by synthesized taps. GET (not POST) deliberately — it is
|
|
161
|
+
// idempotent-ish debug tooling meant to be curl-able, like everything else here.
|
|
162
|
+
method == "GET" && path == "/inspect/navigate" ->
|
|
163
|
+
navigateResponse(query).let { (s, b) -> writeJson(client, s, b) }
|
|
152
164
|
method == "GET" && path == "/inspect/crashes" ->
|
|
153
165
|
writeJson(client, 200, crashesJson())
|
|
154
166
|
method == "GET" && path == "/inspect/db" ->
|
|
@@ -177,8 +189,35 @@ object InspectorHttpServer {
|
|
|
177
189
|
return String(buf, 0, read)
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
private fun healthJson(appId: String): String
|
|
181
|
-
|
|
192
|
+
private fun healthJson(appId: String): String {
|
|
193
|
+
// Process start as epoch millis: elapsedRealtime anchors the boot clock to the wall
|
|
194
|
+
// clock. This is the DETERMINISM primitive — an external relaunch (force-stop +
|
|
195
|
+
// launch) is VERIFIED by this value moving forward, so a walk can prove it started
|
|
196
|
+
// from a fresh process instead of trusting that a retained ViewModel isn't lurking.
|
|
197
|
+
val startedAtMs = System.currentTimeMillis() - android.os.SystemClock.elapsedRealtime() +
|
|
198
|
+
android.os.Process.getStartElapsedRealtime()
|
|
199
|
+
return """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug",""" +
|
|
200
|
+
""""processStartedAtMs":$startedAtMs,"processUptimeMs":${android.os.SystemClock.elapsedRealtime() - android.os.Process.getStartElapsedRealtime()}}"""
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private fun navigateResponse(query: String): Pair<Int, String> {
|
|
204
|
+
val route = query.split('&')
|
|
205
|
+
.firstOrNull { it.startsWith("route=") }
|
|
206
|
+
?.substringAfter('=')
|
|
207
|
+
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
|
208
|
+
?.takeIf { it.isNotBlank() }
|
|
209
|
+
?: return 400 to errorJson("missing route parameter — /inspect/navigate?route=<route>")
|
|
210
|
+
val error = NavInspector.navigate(route)
|
|
211
|
+
return if (error == null) {
|
|
212
|
+
200 to """{"ok":true,"route":${JsonPrimitive(route)}}"""
|
|
213
|
+
} else if (error.startsWith("nav host not composed")) {
|
|
214
|
+
503 to errorJson(error)
|
|
215
|
+
} else if (error.startsWith("unknown route")) {
|
|
216
|
+
404 to errorJson(error)
|
|
217
|
+
} else {
|
|
218
|
+
500 to errorJson(error)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
182
221
|
|
|
183
222
|
private fun treeResponse(): Pair<Int, String> {
|
|
184
223
|
val root = ComposeRootRegistry.current()
|
|
@@ -251,9 +290,18 @@ object InspectorHttpServer {
|
|
|
251
290
|
}
|
|
252
291
|
|
|
253
292
|
/**
|
|
254
|
-
* PNG of the current Compose root.
|
|
255
|
-
*
|
|
256
|
-
*
|
|
293
|
+
* PNG of the current Compose root. Pixels are read with [PixelCopy] (API 26+) from the
|
|
294
|
+
* COMPOSITED window surface — the same source `adb screencap` reads — so a capture can
|
|
295
|
+
* never replay a stale Compose layer recording. The software fallback (`View.draw` into a
|
|
296
|
+
* bitmap canvas) replays recorded display lists, and a nav-transition `graphicsLayer`
|
|
297
|
+
* whose recording predates the current frame replays the PREVIOUS screen: byte-identical
|
|
298
|
+
* "screenshots" of two different screens, detectable only by hash-compare. PixelCopy is
|
|
299
|
+
* therefore the primary path; the draw fallback remains for pre-26, windowless roots, and
|
|
300
|
+
* dialog/popup roots (their content lives in a different window than the Activity's).
|
|
301
|
+
*
|
|
302
|
+
* The capture runs on the MAIN thread (views are not thread-safe; PixelCopy's listener is
|
|
303
|
+
* delivered there too); PNG compression — tens of ms for a full screen — happens back on
|
|
304
|
+
* the server thread so the UI never pays for it.
|
|
257
305
|
*/
|
|
258
306
|
private fun screenshotResponse(client: Socket) {
|
|
259
307
|
val root = ComposeRootRegistry.current()
|
|
@@ -267,22 +315,35 @@ object InspectorHttpServer {
|
|
|
267
315
|
val bitmapRef = AtomicReference<Bitmap?>()
|
|
268
316
|
val errorRef = AtomicReference<String?>()
|
|
269
317
|
val latch = CountDownLatch(1)
|
|
270
|
-
Handler(Looper.getMainLooper())
|
|
318
|
+
val main = Handler(Looper.getMainLooper())
|
|
319
|
+
main.post {
|
|
271
320
|
try {
|
|
272
321
|
val view = root.view
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
// PixelCopy only when this root actually lives in the Activity's window —
|
|
323
|
+
// a dialog/popup root has its own window, and copying the Activity's would
|
|
324
|
+
// capture the screen BENEATH it. Those fall back to the software draw.
|
|
325
|
+
val window = windowOf(view)?.takeIf { it.decorView === view.rootView }
|
|
326
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && window != null) {
|
|
327
|
+
val loc = IntArray(2).also(view::getLocationInWindow)
|
|
328
|
+
val bmp = Bitmap.createBitmap(
|
|
329
|
+
view.width.coerceAtLeast(1),
|
|
330
|
+
view.height.coerceAtLeast(1),
|
|
331
|
+
Bitmap.Config.ARGB_8888,
|
|
332
|
+
)
|
|
333
|
+
val src = Rect(loc[0], loc[1], loc[0] + bmp.width, loc[1] + bmp.height)
|
|
334
|
+
PixelCopy.request(window, src, bmp, { result ->
|
|
335
|
+
try {
|
|
336
|
+
// No composited frame to copy (mid-transition, surface torn down):
|
|
337
|
+
// fall back rather than fail — a fallback frame beats no frame.
|
|
338
|
+
bitmapRef.set(if (result == PixelCopy.SUCCESS) bmp else softwareDraw(view))
|
|
339
|
+
} catch (t: Throwable) {
|
|
340
|
+
errorRef.set("failed to render screenshot: ${t.message}")
|
|
341
|
+
}
|
|
342
|
+
latch.countDown()
|
|
343
|
+
}, main)
|
|
344
|
+
return@post // countDown happens in the PixelCopy listener above
|
|
345
|
+
}
|
|
346
|
+
bitmapRef.set(softwareDraw(view))
|
|
286
347
|
} catch (t: Throwable) {
|
|
287
348
|
errorRef.set("failed to render screenshot: ${t.message}")
|
|
288
349
|
}
|
|
@@ -309,6 +370,34 @@ object InspectorHttpServer {
|
|
|
309
370
|
writeResponse(client, 200, bytes, PNG_TYPE)
|
|
310
371
|
}
|
|
311
372
|
|
|
373
|
+
/** The view's host [Window], unwrapped through the ContextWrapper chain. MAIN thread. */
|
|
374
|
+
private fun windowOf(view: View): Window? {
|
|
375
|
+
var ctx = view.context
|
|
376
|
+
while (ctx is ContextWrapper) {
|
|
377
|
+
if (ctx is Activity) return ctx.window
|
|
378
|
+
ctx = ctx.baseContext
|
|
379
|
+
}
|
|
380
|
+
return null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Software-canvas capture — re-issues the view's draw. Correct for laid-out static content,
|
|
385
|
+
* but can replay a stale `graphicsLayer` recording mid-transition (see [screenshotResponse]);
|
|
386
|
+
* used only where PixelCopy can't be (pre-API-26, windowless, or dialog roots). MAIN thread.
|
|
387
|
+
*/
|
|
388
|
+
private fun softwareDraw(view: View): Bitmap =
|
|
389
|
+
try {
|
|
390
|
+
// androidx.core.view.drawToBitmap (core-ktx — already an androidMain dep).
|
|
391
|
+
view.drawToBitmap()
|
|
392
|
+
} catch (t: Throwable) {
|
|
393
|
+
// Not laid out yet / hardware path refused — plain Canvas draw fallback.
|
|
394
|
+
Bitmap.createBitmap(
|
|
395
|
+
view.width.coerceAtLeast(1),
|
|
396
|
+
view.height.coerceAtLeast(1),
|
|
397
|
+
Bitmap.Config.ARGB_8888,
|
|
398
|
+
).also { view.draw(Canvas(it)) }
|
|
399
|
+
}
|
|
400
|
+
|
|
312
401
|
/**
|
|
313
402
|
* Dispatch a synthetic tap (ACTION_DOWN, then ACTION_UP ~50ms later) to the topmost
|
|
314
403
|
* Compose root, on the MAIN thread. Coordinates are root-relative px — exactly the
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt
CHANGED
|
@@ -26,6 +26,9 @@ import kotlin.math.roundToInt
|
|
|
26
26
|
* - `role` string|null — [SemanticsProperties.Role] (e.g. "Button").
|
|
27
27
|
* - `clickable` boolean — presence of [SemanticsActions.OnClick].
|
|
28
28
|
* - `disabled` boolean — presence of [SemanticsProperties.Disabled].
|
|
29
|
+
* - `size` {width,height} — the FULL composed (unclipped) size; `bounds` is the visible
|
|
30
|
+
* slice after ancestor clipping (a scroll fold truncates it). The a11y audit
|
|
31
|
+
* judges touch targets on `size` so fold-clipped rows never false-flag.
|
|
29
32
|
*/
|
|
30
33
|
object LiveSemanticsJson {
|
|
31
34
|
|
|
@@ -49,6 +52,7 @@ object LiveSemanticsJson {
|
|
|
49
52
|
put("clickable", JsonPrimitive(node.isClickable()))
|
|
50
53
|
put("disabled", JsonPrimitive(node.isDisabled()))
|
|
51
54
|
put("bounds", node.boundsJson())
|
|
55
|
+
put("size", node.sizeJson())
|
|
52
56
|
put("designToken", node.designTokenJson())
|
|
53
57
|
put("children", buildJsonArray {
|
|
54
58
|
node.children.forEach { add(nodeToJson(it)) }
|
|
@@ -87,6 +91,12 @@ object LiveSemanticsJson {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/** Full composed (unclipped) size — see the class doc's `size` contract note. */
|
|
95
|
+
private fun SemanticsNode.sizeJson(): JsonObject = buildJsonObject {
|
|
96
|
+
put("width", JsonPrimitive(size.width))
|
|
97
|
+
put("height", JsonPrimitive(size.height))
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
private fun SemanticsNode.designTokenJson(): JsonElement {
|
|
91
101
|
val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
|
|
92
102
|
return buildJsonObject {
|
package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt
CHANGED
|
@@ -28,4 +28,35 @@ object NavInspector {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
fun current(): Snapshot = state.get()
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The jump half (`GET /inspect/navigate?route=…`): request navigation to [route] on the
|
|
34
|
+
* main thread and wait (bounded) for the outcome. Coverage tool, not a behaviour proof —
|
|
35
|
+
* see [NavInspectionHook.navigator]. Outcomes are honest, never fabricated:
|
|
36
|
+
* - `null` on success (the NavController accepted the route);
|
|
37
|
+
* - a message when no navigator is registered (nav host not composed yet — retry), when
|
|
38
|
+
* the route is unknown (NavController's own IllegalArgumentException, surfaced
|
|
39
|
+
* verbatim), or when the main thread didn't get to it in time.
|
|
40
|
+
*/
|
|
41
|
+
fun navigate(route: String, timeoutMs: Long = 5_000): String? {
|
|
42
|
+
val error = java.util.concurrent.atomic.AtomicReference<String?>(null)
|
|
43
|
+
val latch = java.util.concurrent.CountDownLatch(1)
|
|
44
|
+
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
|
45
|
+
val nav = NavInspectionHook.navigator
|
|
46
|
+
if (nav == null) {
|
|
47
|
+
error.set("nav host not composed yet — no navigator registered. Retry shortly.")
|
|
48
|
+
} else {
|
|
49
|
+
try {
|
|
50
|
+
nav(route)
|
|
51
|
+
} catch (e: IllegalArgumentException) {
|
|
52
|
+
error.set("unknown route ${'"'}$route${'"'} — ${e.message}")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
latch.countDown()
|
|
56
|
+
}
|
|
57
|
+
if (!latch.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
|
58
|
+
return "main thread did not service the navigation within ${timeoutMs}ms"
|
|
59
|
+
}
|
|
60
|
+
return error.get()
|
|
61
|
+
}
|
|
31
62
|
}
|
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
package __PACKAGE__.presentation.brand
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.background
|
|
4
|
+
import androidx.compose.foundation.layout.Box
|
|
5
|
+
import androidx.compose.foundation.layout.Row
|
|
6
|
+
import androidx.compose.foundation.layout.Spacer
|
|
7
|
+
import androidx.compose.foundation.layout.size
|
|
8
|
+
import androidx.compose.foundation.layout.width
|
|
9
|
+
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
10
|
+
import androidx.compose.material3.MaterialTheme
|
|
11
|
+
import androidx.compose.material3.Text
|
|
12
|
+
import androidx.compose.runtime.Composable
|
|
13
|
+
import androidx.compose.ui.Alignment
|
|
14
|
+
import androidx.compose.ui.Modifier
|
|
15
|
+
import androidx.compose.ui.draw.clip
|
|
16
|
+
import androidx.compose.ui.semantics.contentDescription
|
|
17
|
+
import androidx.compose.ui.semantics.semantics
|
|
18
|
+
import androidx.compose.ui.semantics.testTag
|
|
19
|
+
import androidx.compose.ui.text.font.FontWeight
|
|
20
|
+
import androidx.compose.ui.unit.Dp
|
|
21
|
+
import androidx.compose.ui.unit.dp
|
|
22
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
23
|
+
|
|
24
|
+
// The app's brand mark — a guided placeholder, not final art. Brand is its own governed
|
|
25
|
+
// category (presentation/brand/, distinct from the components registry): identity, not
|
|
26
|
+
// design-system vocabulary. This starter renders a theme-tinted badge with the app's
|
|
27
|
+
// initial plus a wordmark, so "we need a logo" has a home from day one. Replace the badge
|
|
28
|
+
// with a DRAWN mark (Canvas paths — see how a real one is built: a shape punched out of
|
|
29
|
+
// the badge, scaled from a unit box) when the identity lands; keep the testTags.
|
|
30
|
+
|
|
31
|
+
private const val APP_NAME = "__APP_NAME__"
|
|
32
|
+
|
|
33
|
+
/** The badge alone — compact header, avatar seats, launcher-adjacent surfaces. */
|
|
34
|
+
@Composable
|
|
35
|
+
fun BrandMark(
|
|
36
|
+
modifier: Modifier = Modifier,
|
|
37
|
+
size: Dp = 32.dp,
|
|
38
|
+
) {
|
|
39
|
+
Box(
|
|
40
|
+
modifier = modifier
|
|
41
|
+
.size(size)
|
|
42
|
+
.clip(RoundedCornerShape(size * 0.28f))
|
|
43
|
+
.background(__THEME_PREFIX__Colors.Primary)
|
|
44
|
+
.semantics { testTag = "brand_mark"; contentDescription = APP_NAME },
|
|
45
|
+
contentAlignment = Alignment.Center,
|
|
46
|
+
) {
|
|
47
|
+
Text(
|
|
48
|
+
text = APP_NAME.trim().take(1).uppercase(),
|
|
49
|
+
style = MaterialTheme.typography.titleLarge,
|
|
50
|
+
fontWeight = FontWeight.Bold,
|
|
51
|
+
color = __THEME_PREFIX__Colors.OnPrimary,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Badge + wordmark, for headers and about/launch surfaces. */
|
|
57
|
+
@Composable
|
|
58
|
+
fun BrandWordmark(
|
|
59
|
+
modifier: Modifier = Modifier,
|
|
60
|
+
markSize: Dp = 28.dp,
|
|
61
|
+
) {
|
|
62
|
+
Row(
|
|
63
|
+
modifier = modifier.semantics { testTag = "brand_wordmark" },
|
|
64
|
+
verticalAlignment = Alignment.CenterVertically,
|
|
65
|
+
) {
|
|
66
|
+
BrandMark(size = markSize)
|
|
67
|
+
Spacer(Modifier.width(10.dp))
|
|
68
|
+
Text(
|
|
69
|
+
text = APP_NAME,
|
|
70
|
+
style = MaterialTheme.typography.titleLarge,
|
|
71
|
+
fontWeight = FontWeight.Bold,
|
|
72
|
+
color = MaterialTheme.colorScheme.onSurface,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -30,9 +30,9 @@ fun AppPrimaryButton(
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* The low-emphasis text button, with the same 48 dp floor as [AppPrimaryButton].
|
|
34
|
-
*
|
|
35
|
-
* is a registry addition a human approves, not a local tweak.
|
|
33
|
+
* The low-emphasis text button, with the same 48 dp floor as [AppPrimaryButton]. The
|
|
34
|
+
* registry's buttons are these two plus [AppIconButton] — a new variant (loading,
|
|
35
|
+
* destructive, FAB) is a registry addition a human approves, not a local tweak.
|
|
36
36
|
*/
|
|
37
37
|
@Composable
|
|
38
38
|
fun AppTextButton(
|
|
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Row
|
|
|
4
4
|
import androidx.compose.foundation.layout.RowScope
|
|
5
5
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
6
6
|
import androidx.compose.foundation.layout.padding
|
|
7
|
+
import androidx.compose.material.icons.Icons
|
|
8
|
+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
7
9
|
import androidx.compose.material3.MaterialTheme
|
|
8
10
|
import androidx.compose.material3.Text
|
|
9
11
|
import androidx.compose.runtime.Composable
|
|
@@ -20,6 +22,11 @@ import androidx.compose.ui.unit.dp
|
|
|
20
22
|
* handling (`BaseScreen` owns insets, SHELL-03). A collapsing toolbar would be a
|
|
21
23
|
* registry addition, not a default.
|
|
22
24
|
*
|
|
25
|
+
* The back affordance is a Material icon button with the auto-mirrored arrow
|
|
26
|
+
* (RTL-correct), via [AppIconButton] so the 48 dp touch-target floor holds by
|
|
27
|
+
* construction — never a text link. It renders only when [onBack] is non-null, so a tab
|
|
28
|
+
* root never shows a back control.
|
|
29
|
+
*
|
|
23
30
|
* @param title Headline text, rendered in `headlineMedium`.
|
|
24
31
|
* @param screenTag Feature slug; derives the `<screenTag>_title` and `<screenTag>_back` tags.
|
|
25
32
|
* @param onBack Non-null renders a 48 dp back affordance left of the title.
|
|
@@ -38,9 +45,11 @@ fun AppHeader(
|
|
|
38
45
|
verticalAlignment = Alignment.CenterVertically,
|
|
39
46
|
) {
|
|
40
47
|
if (onBack != null) {
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
AppIconButton(
|
|
49
|
+
icon = Icons.AutoMirrored.Filled.ArrowBack,
|
|
50
|
+
contentDescription = "Back",
|
|
43
51
|
onClick = onBack,
|
|
52
|
+
tint = MaterialTheme.colorScheme.onSurface,
|
|
44
53
|
modifier = Modifier.semantics { testTag = "${screenTag}_back" },
|
|
45
54
|
)
|
|
46
55
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package __PACKAGE__.presentation.components
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.size
|
|
4
|
+
import androidx.compose.material3.Icon
|
|
5
|
+
import androidx.compose.material3.IconButton
|
|
6
|
+
import androidx.compose.material3.LocalContentColor
|
|
7
|
+
import androidx.compose.runtime.Composable
|
|
8
|
+
import androidx.compose.ui.Modifier
|
|
9
|
+
import androidx.compose.ui.graphics.Color
|
|
10
|
+
import androidx.compose.ui.graphics.vector.ImageVector
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The registry's icon button: M3 `IconButton` held to the 48 dp touch-target floor.
|
|
14
|
+
*
|
|
15
|
+
* Stock M3 `IconButton` defaults to a 40x40 dp target — below WCAG 2.2 SC 2.5.8 and this
|
|
16
|
+
* harness's own `audit_a11y` bar — so every raw use is a violation waiting to be measured
|
|
17
|
+
* (and historically why a text link masqueraded as a back button). This wrapper clears the
|
|
18
|
+
* floor once, by construction, the same way [AppPrimaryButton] does for filled buttons.
|
|
19
|
+
*
|
|
20
|
+
* A [contentDescription] is REQUIRED, not defaulted: an icon-only control with no label is
|
|
21
|
+
* invisible to screen readers (`missing-label` in the same audit). Pass what the control
|
|
22
|
+
* does ("Back", "Add entry"), never what the icon looks like.
|
|
23
|
+
*
|
|
24
|
+
* @param icon The vector to render (e.g. `Icons.AutoMirrored.Filled.ArrowBack`).
|
|
25
|
+
* @param contentDescription What the control does, for screen readers.
|
|
26
|
+
* @param tint Icon tint; defaults to the current content color.
|
|
27
|
+
*/
|
|
28
|
+
@Composable
|
|
29
|
+
fun AppIconButton(
|
|
30
|
+
icon: ImageVector,
|
|
31
|
+
contentDescription: String,
|
|
32
|
+
onClick: () -> Unit,
|
|
33
|
+
modifier: Modifier = Modifier,
|
|
34
|
+
enabled: Boolean = true,
|
|
35
|
+
tint: Color = Color.Unspecified,
|
|
36
|
+
) {
|
|
37
|
+
IconButton(
|
|
38
|
+
onClick = onClick,
|
|
39
|
+
enabled = enabled,
|
|
40
|
+
modifier = modifier.size(AppButtonDefaults.MinTouchTarget),
|
|
41
|
+
) {
|
|
42
|
+
Icon(
|
|
43
|
+
imageVector = icon,
|
|
44
|
+
contentDescription = contentDescription,
|
|
45
|
+
tint = if (tint == Color.Unspecified) LocalContentColor.current else tint,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -21,11 +21,20 @@ fun AppNavHost() {
|
|
|
21
21
|
// androidDebug inspector registered a listener (see NavInspectionHook.kt). Best-effort:
|
|
22
22
|
// `currentBackStack` is a live snapshot, not a durable history.
|
|
23
23
|
LaunchedEffect(navController) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
// The jump half of the same seam: lets the debug inspector navigate by route
|
|
25
|
+
// (its navigate endpoint) so walkthrough coverage enumerates the graph instead of
|
|
26
|
+
// synthesizing taps. Registered/cleared with the controller's composition lifetime;
|
|
27
|
+
// NavController rejects unknown routes itself (IllegalArgumentException).
|
|
28
|
+
NavInspectionHook.navigator = { route -> navController.navigate(route) }
|
|
29
|
+
try {
|
|
30
|
+
navController.currentBackStack.collect { stack ->
|
|
31
|
+
NavInspectionHook.listener?.invoke(
|
|
32
|
+
navController.currentDestination?.route,
|
|
33
|
+
stack.mapNotNull { it.destination.route },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
NavInspectionHook.navigator = null
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -18,4 +18,14 @@ object NavInspectionHook {
|
|
|
18
18
|
* durable history).
|
|
19
19
|
*/
|
|
20
20
|
var listener: ((currentRoute: String?, backStack: List<String>) -> Unit)? = null
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The JUMP half of the seam: [AppNavHost] registers a function that navigates the real
|
|
24
|
+
* `NavController` to a route. Debug-only callers (the debug inspector's navigate endpoint) use it
|
|
25
|
+
* so a walkthrough can enumerate the nav graph mechanically — coverage by route-jump —
|
|
26
|
+
* instead of synthesizing taps with guessed settle times. Taps remain the tool for
|
|
27
|
+
* BEHAVIOUR proofs (a toggle, a search); this is for COVERAGE. `null` outside debug
|
|
28
|
+
* inspection, and must only ever be invoked on the main thread (NavController requirement).
|
|
29
|
+
*/
|
|
30
|
+
var navigator: ((route: String) -> Unit)? = null
|
|
21
31
|
}
|