create-cmp-cli 0.8.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 (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  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 +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -0,0 +1,62 @@
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
+
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
+ }
62
+ }
@@ -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,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
+ }
@@ -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]. 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
+ */
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,63 @@
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.material.icons.Icons
8
+ import androidx.compose.material.icons.automirrored.filled.ArrowBack
9
+ import androidx.compose.material3.MaterialTheme
10
+ import androidx.compose.material3.Text
11
+ import androidx.compose.runtime.Composable
12
+ import androidx.compose.ui.Alignment
13
+ import androidx.compose.ui.Modifier
14
+ import androidx.compose.ui.semantics.semantics
15
+ import androidx.compose.ui.semantics.testTag
16
+ import androidx.compose.ui.unit.dp
17
+
18
+ /**
19
+ * The screen header: a headline row with an optional back affordance and a trailing
20
+ * actions slot, tagged `<screenTag>_title` and `<screenTag>_back`. Deliberately not an
21
+ * M3 `TopAppBar` — no scroll behaviors, no center-aligned variants, no window-inset
22
+ * handling (`BaseScreen` owns insets, SHELL-03). A collapsing toolbar would be a
23
+ * registry addition, not a default.
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
+ *
30
+ * @param title Headline text, rendered in `headlineMedium`.
31
+ * @param screenTag Feature slug; derives the `<screenTag>_title` and `<screenTag>_back` tags.
32
+ * @param onBack Non-null renders a 48 dp back affordance left of the title.
33
+ * @param actions Trailing slot at the row's end, for per-screen controls.
34
+ */
35
+ @Composable
36
+ fun AppHeader(
37
+ title: String,
38
+ screenTag: String,
39
+ modifier: Modifier = Modifier,
40
+ onBack: (() -> Unit)? = null,
41
+ actions: @Composable RowScope.() -> Unit = {},
42
+ ) {
43
+ Row(
44
+ modifier = modifier.fillMaxWidth().padding(bottom = 12.dp),
45
+ verticalAlignment = Alignment.CenterVertically,
46
+ ) {
47
+ if (onBack != null) {
48
+ AppIconButton(
49
+ icon = Icons.AutoMirrored.Filled.ArrowBack,
50
+ contentDescription = "Back",
51
+ onClick = onBack,
52
+ tint = MaterialTheme.colorScheme.onSurface,
53
+ modifier = Modifier.semantics { testTag = "${screenTag}_back" },
54
+ )
55
+ }
56
+ Text(
57
+ text = title,
58
+ style = MaterialTheme.typography.headlineMedium,
59
+ modifier = Modifier.weight(1f).semantics { testTag = "${screenTag}_title" },
60
+ )
61
+ actions()
62
+ }
63
+ }
@@ -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
+ }
@@ -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(