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.
Files changed (29) hide show
  1. package/README.md +16 -0
  2. package/package.json +1 -1
  3. package/src/lib/tabs.mjs +6 -0
  4. package/template/CLAUDE.md +24 -6
  5. package/template/README.md +9 -0
  6. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  7. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
  8. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  9. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  10. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  11. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
  12. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
  13. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  14. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
  15. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
  16. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  17. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
  18. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  19. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  20. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  21. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
  22. package/template/docs/ARCHITECTURE.md +55 -0
  23. package/template/docs/TESTING.md +7 -0
  24. package/template/qa/e2e/smoke.yaml +6 -0
  25. package/template/qa/lib/a11y.mjs +17 -8
  26. package/template/qa/lib/approvals.mjs +44 -28
  27. package/template/qa/verify.mjs +63 -6
  28. package/template/qa/walkthrough.mjs +499 -0
  29. 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.9.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__
@@ -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. **Design system** — `presentation/theme/Theme.kt`, `presentation/theme/Tokens.kt`.
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
- 3. **Components** — every `presentation/components/*.kt` (a dynamic, sorted glob). Once
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
@@ -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`.)
@@ -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
  }
@@ -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
- """{"status":"ok","schemaVersion":1,"source":"live-android","appId":${JsonPrimitive(appId)},"buildType":"debug"}"""
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. The Bitmap is rendered on the MAIN thread (views are
255
- * not thread-safe); PNG compression tens of ms for a full screen happens back on the
256
- * server thread so the UI never pays for it.
293
+ * PNG of the current Compose root. Pixels are read with [PixelCopy] (API 26+) from the
294
+ * COMPOSITED window surfacethe same source `adb screencap` readsso 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()).post {
318
+ val main = Handler(Looper.getMainLooper())
319
+ main.post {
271
320
  try {
272
321
  val view = root.view
273
- bitmapRef.set(
274
- try {
275
- // androidx.core.view.drawToBitmap (core-ktx already an androidMain dep).
276
- view.drawToBitmap()
277
- } catch (t: Throwable) {
278
- // Not laid out yet / hardware path refused — plain Canvas draw fallback.
279
- Bitmap.createBitmap(
280
- view.width.coerceAtLeast(1),
281
- view.height.coerceAtLeast(1),
282
- Bitmap.Config.ARGB_8888,
283
- ).also { view.draw(Canvas(it)) }
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
@@ -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 {
@@ -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
  }
@@ -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]. These
34
- * two are the registry's only buttons — a new variant (icon, loading, destructive, FAB)
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
- AppTextButton(
42
- text = "← Back",
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
- navController.currentBackStack.collect { stack ->
25
- NavInspectionHook.listener?.invoke(
26
- navController.currentDestination?.route,
27
- stack.mapNotNull { it.destination.route },
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
  }