create-cmp-cli 0.7.1 → 0.9.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 (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -1,5 +1,6 @@
1
1
  package __PACKAGE__.inspector
2
2
 
3
+ import android.content.Context
3
4
  import android.graphics.Bitmap
4
5
  import android.graphics.Canvas
5
6
  import android.os.Handler
@@ -14,6 +15,7 @@ import java.io.InputStreamReader
14
15
  import java.net.InetAddress
15
16
  import java.net.ServerSocket
16
17
  import java.net.Socket
18
+ import java.net.URLDecoder
17
19
  import java.nio.charset.StandardCharsets
18
20
  import java.util.concurrent.CountDownLatch
19
21
  import java.util.concurrent.TimeUnit
@@ -43,6 +45,15 @@ import kotlin.math.roundToInt
43
45
  * pair to the root view → {"tapped":true,"x":…,"y":…}.
44
46
  * GET /inspect/remote → the self-contained remote-control HTML page (same-origin,
45
47
  * zero CORS): live screenshot + click-to-tap in a browser.
48
+ * GET /inspect/nav → { currentRoute, backStack } — best-effort, reported by the
49
+ * common `NavInspectionHook` seam; empty snapshot before the
50
+ * first navigation event (see [NavInspector]).
51
+ * GET /inspect/crashes → { crashes: [...] } — persisted crash JSON (current boot +
52
+ * previous ones), newest first (see [CrashRecorder]).
53
+ * GET /inspect/db → schema: { tables:[{name,sql}] } via `sqlite_master`.
54
+ * GET /inspect/db?table=<n>&limit=<n> → rows for one table (read-only, bounded; see
55
+ * [DbInspector]). 404 (or empty schema) when the project's
56
+ * `room` feature is off.
46
57
  *
47
58
  * Single-threaded accept loop on a daemon thread = one client at a time = bounded by design.
48
59
  * Failure to bind logs a warning and gives up — the inspector must never crash or block
@@ -67,9 +78,14 @@ object InspectorHttpServer {
67
78
 
68
79
  @Volatile private var started = false
69
80
 
70
- fun start(appId: String) {
81
+ // Set once in [start]; read from the HTTP thread only (crashes/db routes). applicationContext
82
+ // is safe to hold — it never leaks an Activity.
83
+ @Volatile private var appContext: Context? = null
84
+
85
+ fun start(appId: String, context: Context) {
71
86
  if (started) return
72
87
  started = true
88
+ appContext = context.applicationContext
73
89
  val thread = Thread({ serve(appId) }, "cmp-inspector-http")
74
90
  thread.isDaemon = true
75
91
  thread.start()
@@ -114,7 +130,9 @@ object InspectorHttpServer {
114
130
  }
115
131
  val parts = requestLine.split(" ")
116
132
  val method = parts.getOrNull(0) ?: ""
117
- val path = (parts.getOrNull(1) ?: "").substringBefore('?')
133
+ val rawTarget = parts.getOrNull(1) ?: ""
134
+ val path = rawTarget.substringBefore('?')
135
+ val query = rawTarget.substringAfter('?', "")
118
136
 
119
137
  when {
120
138
  method == "GET" && path == "/inspect/health" ->
@@ -129,6 +147,12 @@ object InspectorHttpServer {
129
147
  writeResponse(client, 200, RemoteControlPage.html(appId).toByteArray(StandardCharsets.UTF_8), HTML_TYPE)
130
148
  method == "POST" && path == "/inspect/tap" ->
131
149
  tapResponse(readBody(reader, contentLength)).let { (s, b) -> writeJson(client, s, b) }
150
+ method == "GET" && path == "/inspect/nav" ->
151
+ writeJson(client, 200, navJson())
152
+ method == "GET" && path == "/inspect/crashes" ->
153
+ writeJson(client, 200, crashesJson())
154
+ method == "GET" && path == "/inspect/db" ->
155
+ dbResponse(query).let { (s, b) -> writeJson(client, s, b) }
132
156
  method != "GET" && method != "POST" ->
133
157
  writeJson(client, 405, errorJson("method not allowed"))
134
158
  else ->
@@ -183,6 +207,49 @@ object InspectorHttpServer {
183
207
  }
184
208
  }
185
209
 
210
+ /** { currentRoute, backStack } from [NavInspector] — best-effort, never blocks. */
211
+ private fun navJson(): String {
212
+ val snapshot = NavInspector.current()
213
+ val currentRouteJson = snapshot.currentRoute?.let { JsonPrimitive(it).toString() } ?: "null"
214
+ val backStackJson = snapshot.backStack.joinToString(",") { JsonPrimitive(it).toString() }
215
+ return """{"currentRoute":$currentRouteJson,"backStack":[$backStackJson]}"""
216
+ }
217
+
218
+ /** { crashes:[...] } — each element is a persisted crash JSON document, verbatim. */
219
+ private fun crashesJson(): String {
220
+ val ctx = appContext ?: return """{"crashes":[]}"""
221
+ val crashes = CrashRecorder.readAll(ctx)
222
+ return """{"crashes":[${crashes.joinToString(",")}]}"""
223
+ }
224
+
225
+ /** GET /inspect/db dispatch: no `table` → schema, else → rows for that table. */
226
+ private fun dbResponse(query: String): Pair<Int, String> {
227
+ val params = parseQuery(query)
228
+ val table = params["table"]
229
+ return if (table == null) DbInspector.schema() else DbInspector.rows(table, params["limit"])
230
+ }
231
+
232
+ /** Minimal `a=b&c=d` query-string parser (URL-decoded values). Last value wins on repeats. */
233
+ private fun parseQuery(query: String): Map<String, String> {
234
+ if (query.isEmpty()) return emptyMap()
235
+ val out = mutableMapOf<String, String>()
236
+ for (pair in query.split("&")) {
237
+ if (pair.isEmpty()) continue
238
+ val eq = pair.indexOf('=')
239
+ val key = if (eq >= 0) pair.substring(0, eq) else pair
240
+ val value = if (eq >= 0) pair.substring(eq + 1) else ""
241
+ out[urlDecode(key)] = urlDecode(value)
242
+ }
243
+ return out
244
+ }
245
+
246
+ private fun urlDecode(s: String): String =
247
+ try {
248
+ URLDecoder.decode(s, "UTF-8")
249
+ } catch (t: Throwable) {
250
+ s
251
+ }
252
+
186
253
  /**
187
254
  * PNG of the current Compose root. The Bitmap is rendered on the MAIN thread (views are
188
255
  * not thread-safe); PNG compression — tens of ms for a full screen — happens back on the
@@ -3,14 +3,18 @@ package __PACKAGE__.inspector
3
3
  import android.app.Application
4
4
 
5
5
  /**
6
- * DEBUG variant: install the Compose root registry (must happen BEFORE any Activity so the
7
- * `onViewCreatedCallback` hook catches every root), then start the loopback-only inspection
8
- * server on 127.0.0.1:9500. Reach it from the host via `adb forward tcp:9500 tcp:9500`.
6
+ * DEBUG variant: install the Compose root registry and the nav-inspection listener (both must
7
+ * happen BEFORE any Activity — the registry so `onViewCreatedCallback` catches every root, the
8
+ * nav listener so the first `AppNavHost` composition is observed), chain in the crash recorder,
9
+ * then start the loopback-only inspection server on 127.0.0.1:9500. Reach it from the host via
10
+ * `adb forward tcp:9500 tcp:9500`.
9
11
  *
10
12
  * The release source set carries a same-signature no-op twin — the compiler picks the variant
11
13
  * body, so release builds contain no inspector code at all (structural absence, not a flag).
12
14
  */
13
15
  fun Application.startInspector() {
14
16
  ComposeRootRegistry.install()
15
- InspectorHttpServer.start(appId = packageName)
17
+ NavInspector.install()
18
+ CrashRecorder.install(this)
19
+ InspectorHttpServer.start(appId = packageName, context = this)
16
20
  }
@@ -0,0 +1,31 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import __PACKAGE__.presentation.navigation.NavInspectionHook
4
+ import java.util.concurrent.atomic.AtomicReference
5
+
6
+ /**
7
+ * Debug-only sink for [NavInspectionHook]: [install] registers a listener that stores the
8
+ * latest route/back-stack snapshot the common `AppNavHost` reports, so `GET /inspect/nav` can
9
+ * read it synchronously from the HTTP thread without touching Compose state directly.
10
+ *
11
+ * Best-effort by design: until the first navigation event fires (cold start, before
12
+ * `AppNavHost` has composed at least once), [current] reports an empty snapshot rather than
13
+ * blocking or erroring — mirrors the tree/screenshot routes' "not ready yet, retry" posture,
14
+ * just without the 503 (an empty nav snapshot is a valid, if uninteresting, answer).
15
+ */
16
+ object NavInspector {
17
+
18
+ data class Snapshot(val currentRoute: String?, val backStack: List<String>)
19
+
20
+ private val EMPTY = Snapshot(currentRoute = null, backStack = emptyList())
21
+ private val state = AtomicReference(EMPTY)
22
+
23
+ /** Must run before the first Activity's setContent — same timing rule as [ComposeRootRegistry]. */
24
+ fun install() {
25
+ NavInspectionHook.listener = { current, backStack ->
26
+ state.set(Snapshot(current, backStack))
27
+ }
28
+ }
29
+
30
+ fun current(): Snapshot = state.get()
31
+ }
@@ -0,0 +1,32 @@
1
+ package __PACKAGE__.data
2
+
3
+ import __PACKAGE__.domain.model.DomainError
4
+ import __PACKAGE__.domain.result.AppResult
5
+ import kotlin.coroutines.cancellation.CancellationException
6
+
7
+ /**
8
+ * The data layer's ONLY exception-catching mechanism (`specs/app-base.spec.md` ARCH-08).
9
+ * Repository implementations wrap their I/O in this instead of writing `try`/`catch` —
10
+ * it is the single translation point where infrastructure exceptions become typed
11
+ * [DomainError] values, and it enforces the one non-negotiable rule of coroutine error
12
+ * handling:
13
+ *
14
+ * **`CancellationException` is ALWAYS rethrown, never mapped.** Swallowing it breaks
15
+ * structured concurrency — a cancelled screen would render an error state instead of
16
+ * simply stopping. The conformance gate scans for exactly this guard.
17
+ *
18
+ * [mapError] classifies everything else into your [DomainError] vocabulary; the default
19
+ * files anything unclassified under [DomainError.Unexpected] with the cause preserved
20
+ * for logging (never for display).
21
+ */
22
+ suspend fun <T> suspendRunCatching(
23
+ mapError: (Throwable) -> DomainError = { DomainError.Unexpected(it) },
24
+ block: suspend () -> T,
25
+ ): AppResult<T> =
26
+ try {
27
+ AppResult.Success(block())
28
+ } catch (e: CancellationException) {
29
+ throw e // never mapped: cancellation is not a failure state
30
+ } catch (e: Throwable) {
31
+ AppResult.Failure(mapError(e))
32
+ }
@@ -1,7 +1,9 @@
1
1
  package __PACKAGE__.data.remote
2
2
 
3
+ import __PACKAGE__.data.suspendRunCatching
3
4
  import __PACKAGE__.domain.model.Item
4
5
  import __PACKAGE__.domain.repository.ItemRepository
6
+ import __PACKAGE__.domain.result.AppResult
5
7
  import kotlinx.coroutines.delay
6
8
 
7
9
  // Example data source for the `home` feature. This is intentionally dependency-light
@@ -9,10 +11,15 @@ import kotlinx.coroutines.delay
9
11
  //
10
12
  // Real apps swap this for a Firestore/Ktor source and add a Room cache (see data/local).
11
13
  // The Clean Architecture seam is the ItemRepository interface in the domain layer.
14
+ //
15
+ // The repository is the ONLY exception-translation point: I/O runs inside
16
+ // suspendRunCatching (data/AppResultCatching.kt), which maps infrastructure exceptions
17
+ // to typed DomainError values and ALWAYS rethrows CancellationException. Pass a mapError
18
+ // lambda to classify your real source's exceptions (IOException -> Network, etc).
12
19
  class ItemRepositoryImpl : ItemRepository {
13
- override suspend fun getItems(): List<Item> {
20
+ override suspend fun getItems(): AppResult<List<Item>> = suspendRunCatching {
14
21
  delay(300) // simulate I/O
15
- return listOf(
22
+ listOf(
16
23
  Item("1", "Welcome to __APP_NAME__", "Your Compose Multiplatform app is wired end-to-end."),
17
24
  Item("2", "Clean Architecture", "presentation → domain → data, with Koin DI."),
18
25
  Item("3", "Edge-to-edge, pre-solved", "BaseScreen owns the window insets for you."),
@@ -0,0 +1,21 @@
1
+ package __PACKAGE__.domain.model
2
+
3
+ /**
4
+ * The typed failure vocabulary of the domain layer — every failure a repository can report
5
+ * is one of these KINDS. No user-facing message strings live here: mapping a kind to copy
6
+ * is the presentation layer's job (see the exemplar ViewModel's `toUserMessage()`), so the
7
+ * domain stays translatable and UI-copy changes never touch this layer.
8
+ *
9
+ * Extend with the kinds YOUR sources can actually produce (e.g. `Unauthorized`, `Conflict`)
10
+ * — the data layer's `suspendRunCatching` mapper is the single place they are assigned.
11
+ */
12
+ sealed interface DomainError {
13
+ /** The source was unreachable — connectivity, DNS, timeouts. */
14
+ data object Network : DomainError
15
+
16
+ /** The requested entity does not exist at the source. */
17
+ data object NotFound : DomainError
18
+
19
+ /** Anything not yet classified. Carries the cause for logging — never for display. */
20
+ data class Unexpected(val cause: Throwable? = null) : DomainError
21
+ }
@@ -1,8 +1,11 @@
1
1
  package __PACKAGE__.domain.repository
2
2
 
3
3
  import __PACKAGE__.domain.model.Item
4
+ import __PACKAGE__.domain.result.AppResult
4
5
 
5
6
  // Domain-facing contract. Presentation depends on THIS, never on a concrete data source.
7
+ // One-shot operations return AppResult — they never throw (ARCH-06): failures cross the
8
+ // boundary as typed DomainError values, translated inside the data implementation.
6
9
  interface ItemRepository {
7
- suspend fun getItems(): List<Item>
10
+ suspend fun getItems(): AppResult<List<Item>>
8
11
  }
@@ -0,0 +1,23 @@
1
+ package __PACKAGE__.domain.result
2
+
3
+ import __PACKAGE__.domain.model.DomainError
4
+
5
+ /**
6
+ * The typed result that crosses the data → domain → presentation boundary. One-shot
7
+ * repository operations return `AppResult<T>`, never throw (`specs/app-base.spec.md`
8
+ * ARCH-06) — so a ViewModel exhaustively `when`s over Success/Failure instead of
9
+ * catching exceptions (ARCH-07).
10
+ *
11
+ * Deliberately our own type rather than `kotlin.Result`: the stdlib Result carries an
12
+ * untyped Throwable, which would put raw exceptions right back on the boundary this
13
+ * type exists to keep them off. A [Failure] carries a typed [DomainError] kind instead
14
+ * (the same call Now in Android makes with its own Result).
15
+ *
16
+ * Cancellation is NOT a result: `CancellationException` propagates (structured
17
+ * concurrency), enforced at the single translation point — `suspendRunCatching` in
18
+ * `data/AppResultCatching.kt` (ARCH-08).
19
+ */
20
+ sealed interface AppResult<out T> {
21
+ data class Success<out T>(val value: T) : AppResult<T>
22
+ data class Failure(val error: DomainError) : AppResult<Nothing>
23
+ }
@@ -2,11 +2,14 @@ package __PACKAGE__.domain.usecase
2
2
 
3
3
  import __PACKAGE__.domain.model.Item
4
4
  import __PACKAGE__.domain.repository.ItemRepository
5
+ import __PACKAGE__.domain.result.AppResult
5
6
 
6
7
  // A use case is a single business action. ViewModels depend on use cases, not repositories
7
8
  // directly, so business rules stay testable and out of the presentation layer.
9
+ // The typed result passes through untouched — a use case may combine or transform results,
10
+ // but it never unwraps them into exceptions.
8
11
  class GetItemsUseCase(
9
12
  private val repository: ItemRepository,
10
13
  ) {
11
- suspend operator fun invoke(): List<Item> = repository.getItems()
14
+ suspend operator fun invoke(): AppResult<List<Item>> = repository.getItems()
12
15
  }
@@ -0,0 +1,138 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.clickable
5
+ import androidx.compose.foundation.layout.Arrangement
6
+ import androidx.compose.foundation.layout.Box
7
+ import androidx.compose.foundation.layout.Column
8
+ import androidx.compose.foundation.layout.Row
9
+ import androidx.compose.foundation.layout.defaultMinSize
10
+ import androidx.compose.foundation.layout.fillMaxWidth
11
+ import androidx.compose.foundation.layout.height
12
+ import androidx.compose.foundation.layout.navigationBarsPadding
13
+ import androidx.compose.foundation.layout.padding
14
+ import androidx.compose.foundation.layout.size
15
+ import androidx.compose.foundation.shape.RoundedCornerShape
16
+ import androidx.compose.material3.Icon
17
+ import androidx.compose.material3.Text
18
+ import androidx.compose.runtime.Composable
19
+ import androidx.compose.ui.Alignment
20
+ import androidx.compose.ui.Modifier
21
+ import androidx.compose.ui.draw.clip
22
+ import androidx.compose.ui.semantics.semantics
23
+ import androidx.compose.ui.semantics.testTag
24
+ import androidx.compose.ui.text.font.FontWeight
25
+ import androidx.compose.ui.unit.dp
26
+ import androidx.compose.ui.unit.sp
27
+ import __PACKAGE__.presentation.navigation.AppTab
28
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
29
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
30
+ import __PACKAGE__.presentation.theme.designToken
31
+
32
+ /**
33
+ * The bottom tab bar: one item per tab, icon over label. Owns the 48 dp touch targets,
34
+ * the deterministic `nav_<slug>` testTags, token-bound colors, the navigation-bar inset
35
+ * padding, and the `BottomNavHeight` inspector self-report. Selection state stays with
36
+ * the caller; `AppShell` wires it.
37
+ *
38
+ * @param tabs Tabs in display order; each label also derives its item's `nav_*` testTag.
39
+ * @param selectedIndex Index of the selected tab in [tabs].
40
+ * @param onSelect Called with the index of the tapped tab.
41
+ */
42
+ @Composable
43
+ fun AppBottomBar(
44
+ tabs: List<AppTab>,
45
+ selectedIndex: Int,
46
+ onSelect: (Int) -> Unit,
47
+ modifier: Modifier = Modifier,
48
+ ) {
49
+ Column(modifier.fillMaxWidth()) {
50
+ Box(
51
+ Modifier
52
+ .fillMaxWidth()
53
+ .height(1.dp)
54
+ .background(__THEME_PREFIX__Colors.OutlineVariant)
55
+ )
56
+ Row(
57
+ modifier = Modifier
58
+ .fillMaxWidth()
59
+ .background(__THEME_PREFIX__Colors.Surface)
60
+ // Lift tabs above the gesture pill / 3-button nav (maps to iOS safe area).
61
+ .navigationBarsPadding()
62
+ .height(__THEME_PREFIX__Tokens.BottomNavHeight)
63
+ // Inspector: the bottom-nav container self-reports its height token.
64
+ .designToken(
65
+ tokens = listOf("BottomNavHeight"),
66
+ resolved = mapOf("height" to "${__THEME_PREFIX__Tokens.BottomNavHeight.value.toInt()}dp"),
67
+ )
68
+ .semantics { testTag = "app_bottom_nav" }
69
+ .padding(bottom = 8.dp),
70
+ verticalAlignment = Alignment.CenterVertically,
71
+ horizontalArrangement = Arrangement.SpaceEvenly,
72
+ ) {
73
+ tabs.forEachIndexed { index, tab ->
74
+ NavItem(
75
+ label = tab.label,
76
+ selected = selectedIndex == index,
77
+ onClick = { onSelect(index) },
78
+ ) {
79
+ Icon(
80
+ imageVector = tab.icon,
81
+ contentDescription = tab.label,
82
+ tint = if (selectedIndex == index) __THEME_PREFIX__Colors.Primary
83
+ else __THEME_PREFIX__Colors.OnSurfaceVariant.copy(alpha = 0.55f),
84
+ modifier = Modifier.size(24.dp),
85
+ )
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Deterministic automation tag for a nav item: `nav_` + the label lowercased with every
94
+ * non-[a-z0-9] run collapsed to `_` and trimmed (e.g. "My Stuff!" → `nav_my_stuff`).
95
+ * Must mirror `navSlug` in create-cmp's engine (src/lib/tabs.mjs), which generates
96
+ * `qa/e2e/smoke.yaml`'s id selectors from the configured tabs — keep the two in sync.
97
+ */
98
+ private fun navItemTag(label: String): String =
99
+ "nav_" + label.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_')
100
+
101
+ /**
102
+ * A single tab item: icon over label, a 48 dp minimum touch target, tagged from its
103
+ * label via [navItemTag]. Internal (not private) so the component story
104
+ * (`component.nav-item`) can render it in isolation; absent from the public API surface.
105
+ *
106
+ * @param selected True renders the item in the selected treatment (primary color, bold label).
107
+ * @param icon Icon slot, rendered above the label.
108
+ */
109
+ @Composable
110
+ internal fun NavItem(
111
+ label: String,
112
+ selected: Boolean,
113
+ onClick: () -> Unit,
114
+ icon: @Composable () -> Unit,
115
+ ) {
116
+ Column(
117
+ modifier = Modifier
118
+ .clip(RoundedCornerShape(8.dp))
119
+ .clickable(onClick = onClick)
120
+ // a11y: guarantee the 48dp minimum touch target regardless of label width
121
+ // (the inspector's audit_a11y flags anything smaller).
122
+ .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
123
+ // Durable selection handle (tests/E2E select by testTag, never display text).
124
+ .semantics { testTag = navItemTag(label) }
125
+ .padding(horizontal = 8.dp, vertical = 4.dp),
126
+ horizontalAlignment = Alignment.CenterHorizontally,
127
+ verticalArrangement = Arrangement.Center,
128
+ ) {
129
+ icon()
130
+ Text(
131
+ text = label,
132
+ fontSize = 10.sp,
133
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
134
+ color = if (selected) __THEME_PREFIX__Colors.Primary else __THEME_PREFIX__Colors.OnSurfaceVariant,
135
+ modifier = Modifier.padding(top = 4.dp),
136
+ )
137
+ }
138
+ }
@@ -0,0 +1,56 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.sizeIn
4
+ import androidx.compose.material3.Button
5
+ import androidx.compose.material3.Text
6
+ import androidx.compose.material3.TextButton
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.ui.Modifier
9
+ import androidx.compose.ui.unit.dp
10
+
11
+ /**
12
+ * The filled call-to-action button: M3 `Button` with a 48 dp minimum touch target
13
+ * applied. Stock M3 buttons sit below that floor by default; wrapping them here clears
14
+ * WCAG 2.2 SC 2.5.8 and the harness's `audit_a11y` bar once, for every call site.
15
+ */
16
+ @Composable
17
+ fun AppPrimaryButton(
18
+ text: String,
19
+ onClick: () -> Unit,
20
+ modifier: Modifier = Modifier,
21
+ enabled: Boolean = true,
22
+ ) {
23
+ Button(
24
+ onClick = onClick,
25
+ enabled = enabled,
26
+ modifier = modifier.sizeIn(minWidth = AppButtonDefaults.MinTouchTarget, minHeight = AppButtonDefaults.MinTouchTarget),
27
+ ) {
28
+ Text(text)
29
+ }
30
+ }
31
+
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.
36
+ */
37
+ @Composable
38
+ fun AppTextButton(
39
+ text: String,
40
+ onClick: () -> Unit,
41
+ modifier: Modifier = Modifier,
42
+ enabled: Boolean = true,
43
+ ) {
44
+ TextButton(
45
+ onClick = onClick,
46
+ enabled = enabled,
47
+ modifier = modifier.sizeIn(minWidth = AppButtonDefaults.MinTouchTarget, minHeight = AppButtonDefaults.MinTouchTarget),
48
+ ) {
49
+ Text(text)
50
+ }
51
+ }
52
+
53
+ /** Shared button constants, following the `ComponentDefaults` naming convention. */
54
+ object AppButtonDefaults {
55
+ val MinTouchTarget = 48.dp
56
+ }
@@ -0,0 +1,54 @@
1
+ package __PACKAGE__.presentation.components
2
+
3
+ import androidx.compose.foundation.layout.Row
4
+ import androidx.compose.foundation.layout.RowScope
5
+ import androidx.compose.foundation.layout.fillMaxWidth
6
+ import androidx.compose.foundation.layout.padding
7
+ import androidx.compose.material3.MaterialTheme
8
+ import androidx.compose.material3.Text
9
+ import androidx.compose.runtime.Composable
10
+ import androidx.compose.ui.Alignment
11
+ import androidx.compose.ui.Modifier
12
+ import androidx.compose.ui.semantics.semantics
13
+ import androidx.compose.ui.semantics.testTag
14
+ import androidx.compose.ui.unit.dp
15
+
16
+ /**
17
+ * The screen header: a headline row with an optional back affordance and a trailing
18
+ * actions slot, tagged `<screenTag>_title` and `<screenTag>_back`. Deliberately not an
19
+ * M3 `TopAppBar` — no scroll behaviors, no center-aligned variants, no window-inset
20
+ * handling (`BaseScreen` owns insets, SHELL-03). A collapsing toolbar would be a
21
+ * registry addition, not a default.
22
+ *
23
+ * @param title Headline text, rendered in `headlineMedium`.
24
+ * @param screenTag Feature slug; derives the `<screenTag>_title` and `<screenTag>_back` tags.
25
+ * @param onBack Non-null renders a 48 dp back affordance left of the title.
26
+ * @param actions Trailing slot at the row's end, for per-screen controls.
27
+ */
28
+ @Composable
29
+ fun AppHeader(
30
+ title: String,
31
+ screenTag: String,
32
+ modifier: Modifier = Modifier,
33
+ onBack: (() -> Unit)? = null,
34
+ actions: @Composable RowScope.() -> Unit = {},
35
+ ) {
36
+ Row(
37
+ modifier = modifier.fillMaxWidth().padding(bottom = 12.dp),
38
+ verticalAlignment = Alignment.CenterVertically,
39
+ ) {
40
+ if (onBack != null) {
41
+ AppTextButton(
42
+ text = "← Back",
43
+ onClick = onBack,
44
+ modifier = Modifier.semantics { testTag = "${screenTag}_back" },
45
+ )
46
+ }
47
+ Text(
48
+ text = title,
49
+ style = MaterialTheme.typography.headlineMedium,
50
+ modifier = Modifier.weight(1f).semantics { testTag = "${screenTag}_title" },
51
+ )
52
+ actions()
53
+ }
54
+ }
@@ -15,15 +15,23 @@ import androidx.compose.ui.graphics.Color
15
15
  import __PACKAGE__.presentation.theme.designToken
16
16
 
17
17
  /**
18
- * The insets moat, pre-solved. Every screen wraps its content in [BaseScreen] instead of
19
- * re-deriving edge-to-edge padding. The Activity is edge-to-edge (transparent system bars);
20
- * this Scaffold owns the status-bar / navigation-bar insets in shared code, which also maps
21
- * to iOS safe areas under Compose Multiplatform.
18
+ * The edge-to-edge scaffold that owns system-bar insets in shared code. The Activity
19
+ * draws behind transparent system bars; this component applies status-bar and
20
+ * navigation-bar padding once (mapping to iOS safe areas under Compose Multiplatform),
21
+ * consumes what it applies to prevent doubled padding, and self-reports the applied
22
+ * inset facts to the inspector. Screens wrap their content in it instead of re-deriving
23
+ * insets.
22
24
  *
23
- * - [topBar] / [bottomBar] draw edge-to-edge (e.g. a nav bar that bleeds behind the gesture
24
- * pill) and are responsible for their own inset padding.
25
- * - The content lambda receives padding already accounting for any bars; by default the body
26
- * gets status + navigation bar padding so plain screens are safe with zero ceremony.
25
+ * @param containerColor Background color; `Color.Unspecified` resolves to the theme background.
26
+ * @param applyStatusBarPadding False lets the body draw under the status bar, for
27
+ * full-bleed content that handles the top inset itself.
28
+ * @param applyNavBarPadding False lets the body draw under the navigation bar set it
29
+ * when a bottom bar owns that inset instead.
30
+ * @param topBar Draws edge-to-edge and is responsible for its own inset padding.
31
+ * @param bottomBar Draws edge-to-edge and is responsible for its own inset padding
32
+ * (e.g. a nav bar that bleeds behind the gesture pill).
33
+ * @param content Screen body. Its padding is already applied by the wrapper; the
34
+ * `PaddingValues` are passed through for callers that need the raw values.
27
35
  */
28
36
  @Composable
29
37
  fun BaseScreen(