create-cmp-cli 0.8.0 → 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 (74) hide show
  1. package/README.md +67 -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 +91 -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 +196 -48
  12. package/template/README.md +23 -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/InspectorHttpServer.kt +69 -2
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  18. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  19. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  43. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  44. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  45. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  46. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  47. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  48. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  49. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  50. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  51. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  52. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  53. package/template/docs/ARCHITECTURE.md +317 -34
  54. package/template/docs/TESTING.md +6 -5
  55. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  56. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  57. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  58. package/template/qa/approvals.json +42 -0
  59. package/template/qa/approve.mjs +139 -0
  60. package/template/qa/arch-doc.mjs +69 -0
  61. package/template/qa/comment.mjs +76 -0
  62. package/template/qa/comments.json +4 -0
  63. package/template/qa/golden/home.json +3 -3
  64. package/template/qa/lib/approvals.mjs +806 -0
  65. package/template/qa/lib/arch-doc.mjs +451 -0
  66. package/template/qa/lib/comments.mjs +252 -0
  67. package/template/qa/lib/component-stories.mjs +183 -0
  68. package/template/qa/lib/inputs-hash.mjs +5 -1
  69. package/template/qa/scaffold-feature.mjs +184 -67
  70. package/template/qa/setup-hooks.mjs +33 -0
  71. package/template/qa/verify.mjs +118 -9
  72. package/template/specs/app-base.spec.md +44 -7
  73. package/template/specs/home.spec.md +7 -4
  74. package/template/specs/intent.md +50 -0
@@ -1,26 +1,51 @@
1
1
  package __PACKAGE__.data.remote
2
2
 
3
+ import __PACKAGE__.domain.result.AppResult
3
4
  import kotlin.test.Test
4
5
  import kotlin.test.assertEquals
6
+ import kotlin.test.assertNull
5
7
  import kotlin.test.assertTrue
8
+ import kotlin.test.fail
9
+ import kotlinx.coroutines.launch
6
10
  import kotlinx.coroutines.test.runTest
7
11
 
8
12
  /**
9
13
  * The exemplar data-layer test. [ItemRepositoryImpl] is the template's dependency-light
10
14
  * example source; when you swap it for a real Firestore/Ktor + Room implementation, keep
11
- * this shape: test the repository through its DOMAIN contract, under `runTest` virtual
12
- * time (the simulated I/O delay costs nothing here — delays are skipped, not slept).
15
+ * this shape: test the repository through its DOMAIN contract (AppResult in, never an
16
+ * exception out), under `runTest` virtual time (the simulated I/O delay costs nothing
17
+ * here — delays are skipped, not slept).
13
18
  */
14
19
  class ItemRepositoryImplTest {
15
20
 
16
21
  private val repository = ItemRepositoryImpl()
17
22
 
18
23
  @Test
19
- fun `returns the seeded example items`() = runTest {
20
- val items = repository.getItems()
24
+ fun `returns the seeded example items as Success`() = runTest {
25
+ val items = when (val result = repository.getItems()) {
26
+ is AppResult.Success -> result.value
27
+ is AppResult.Failure -> fail("example source should succeed, got $result")
28
+ }
21
29
 
22
30
  assertTrue(items.isNotEmpty(), "example source should seed items")
23
31
  assertEquals(items.size, items.map { it.id }.toSet().size, "item ids must be unique")
24
32
  assertTrue(items.all { it.title.isNotBlank() }, "every item needs a title")
25
33
  }
34
+
35
+ // SPEC: ARCH-08
36
+ @Test
37
+ fun `cancellation propagates - a cancelled load never completes as a Failure`() = runTest {
38
+ // A REAL cancellation: getItems() is suspended in its simulated I/O when the caller's
39
+ // job is cancelled. suspendRunCatching must rethrow the CancellationException — if it
40
+ // mapped it, `result` would hold a Failure and this test would fail.
41
+ var result: Any? = null
42
+ val job = launch { result = repository.getItems() }
43
+ testScheduler.runCurrent() // enter getItems() up to the suspension point
44
+
45
+ job.cancel()
46
+ job.join()
47
+
48
+ assertTrue(job.isCancelled, "the load job should end cancelled, not completed")
49
+ assertNull(result, "a cancelled load must produce NO result — especially not a Failure")
50
+ }
26
51
  }
@@ -1,15 +1,17 @@
1
1
  package __PACKAGE__.domain.usecase
2
2
 
3
+ import __PACKAGE__.domain.model.DomainError
3
4
  import __PACKAGE__.domain.model.Item
5
+ import __PACKAGE__.domain.result.AppResult
4
6
  import __PACKAGE__.testing.fakes.FakeItemRepository
5
7
  import kotlin.test.Test
6
8
  import kotlin.test.assertEquals
7
- import kotlin.test.assertFailsWith
8
9
  import kotlinx.coroutines.test.runTest
9
10
 
10
11
  /**
11
12
  * The exemplar use-case test. Use cases are pure business actions with no framework
12
13
  * dependencies, so their tests are the simplest in the pyramid: fake in, behavior out.
14
+ * Results stay typed end-to-end — nothing here throws or catches.
13
15
  */
14
16
  class GetItemsUseCaseTest {
15
17
 
@@ -17,21 +19,21 @@ class GetItemsUseCaseTest {
17
19
  private val getItems = GetItemsUseCase(repository)
18
20
 
19
21
  @Test
20
- fun `returns the repository's items`() = runTest {
22
+ fun `returns the repository's items as Success`() = runTest {
21
23
  val expected = listOf(
22
24
  Item(id = "1", title = "First", subtitle = "a"),
23
25
  Item(id = "2", title = "Second", subtitle = "b"),
24
26
  )
25
27
  repository.items = expected
26
28
 
27
- assertEquals(expected, getItems())
29
+ assertEquals(AppResult.Success(expected), getItems())
28
30
  assertEquals(1, repository.getItemsCallCount)
29
31
  }
30
32
 
31
33
  @Test
32
- fun `propagates repository failures to the caller`() = runTest {
33
- repository.shouldFail = true
34
+ fun `passes a typed failure through untouched`() = runTest {
35
+ repository.failure = DomainError.Network
34
36
 
35
- assertFailsWith<IllegalStateException> { getItems() }
37
+ assertEquals(AppResult.Failure(DomainError.Network), getItems())
36
38
  }
37
39
  }
@@ -1,16 +1,16 @@
1
1
  package __PACKAGE__.presentation.home
2
2
 
3
3
  import app.cash.turbine.test
4
+ import __PACKAGE__.domain.model.DomainError
4
5
  import __PACKAGE__.domain.model.Item
5
6
  import __PACKAGE__.domain.usecase.GetItemsUseCase
7
+ import __PACKAGE__.presentation.components.ContentUiState
6
8
  import __PACKAGE__.testing.fakes.FakeItemRepository
7
9
  import kotlin.test.AfterTest
8
10
  import kotlin.test.BeforeTest
9
11
  import kotlin.test.Test
10
12
  import kotlin.test.assertEquals
11
- import kotlin.test.assertNotNull
12
- import kotlin.test.assertNull
13
- import kotlin.test.assertTrue
13
+ import kotlin.test.assertIs
14
14
  import kotlinx.coroutines.Dispatchers
15
15
  import kotlinx.coroutines.ExperimentalCoroutinesApi
16
16
  import kotlinx.coroutines.test.StandardTestDispatcher
@@ -25,6 +25,10 @@ import kotlinx.coroutines.test.setMain
25
25
  * so coroutines run under the test scheduler's virtual time.
26
26
  * - Turbine (`state.test { … }`) for StateFlow assertions.
27
27
  * - Hand-written fakes from `testing/fakes` — never mocks.
28
+ * - Sealed-state assertions: each emission IS one state (`assertEquals` on the state,
29
+ * `assertIs` on the branch) — no boolean-flag poking. The state type is the shared
30
+ * [ContentUiState] (the generalization of this feature's pre-generalization per-feature
31
+ * sealed state).
28
32
  */
29
33
  @OptIn(ExperimentalCoroutinesApi::class)
30
34
  class HomeViewModelTest {
@@ -47,58 +51,66 @@ class HomeViewModelTest {
47
51
  // SPEC: HOME-01
48
52
  @Test
49
53
  fun `starts in loading state`() = runTest(dispatcher) {
54
+ repository.items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
55
+
50
56
  viewModel().state.test {
51
- assertTrue(awaitItem().isLoading, "initial state should be loading")
57
+ assertEquals(ContentUiState.Loading, awaitItem(), "initial state should be Loading")
52
58
  }
53
59
  }
54
60
 
61
+ // SPEC: HOME-02
55
62
  @Test
56
- fun `emits items when repository succeeds`() = runTest(dispatcher) {
57
- repository.items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
63
+ fun `emits Content when the repository returns items`() = runTest(dispatcher) {
64
+ val items = listOf(Item(id = "1", title = "First", subtitle = "sub"))
65
+ repository.items = items
58
66
 
59
67
  viewModel().state.test {
60
- assertTrue(awaitItem().isLoading)
68
+ assertEquals(ContentUiState.Loading, awaitItem())
69
+ assertEquals(ContentUiState.Content(items), awaitItem())
70
+ }
71
+ }
72
+
73
+ // SPEC: HOME-07
74
+ @Test
75
+ fun `emits Empty when the repository succeeds with no items`() = runTest(dispatcher) {
76
+ repository.items = emptyList()
61
77
 
62
- val loaded = awaitItem()
63
- assertEquals(false, loaded.isLoading)
64
- assertEquals(listOf("First"), loaded.items.map { it.title })
65
- assertNull(loaded.errorMessage)
78
+ viewModel().state.test {
79
+ assertEquals(ContentUiState.Loading, awaitItem())
80
+ assertEquals(ContentUiState.Empty, awaitItem())
66
81
  }
67
82
  }
68
83
 
84
+ // SPEC: HOME-03
69
85
  @Test
70
- fun `emits error message when repository fails`() = runTest(dispatcher) {
71
- repository.shouldFail = true
72
- repository.failureMessage = "network down"
86
+ fun `maps a typed failure to presentation copy - never a raw exception message`() = runTest(dispatcher) {
87
+ repository.failure = DomainError.Network
73
88
 
74
89
  viewModel().state.test {
75
- assertTrue(awaitItem().isLoading)
90
+ assertEquals(ContentUiState.Loading, awaitItem())
76
91
 
77
- val failed = awaitItem()
78
- assertEquals(false, failed.isLoading)
79
- assertTrue(failed.items.isEmpty())
80
- assertEquals("network down", failed.errorMessage)
92
+ val failed = assertIs<ContentUiState.Error>(awaitItem())
93
+ assertEquals(DomainError.Network.toUserMessage(), failed.message)
81
94
  }
82
95
  }
83
96
 
84
97
  // SPEC: HOME-04
85
98
  @Test
86
99
  fun `reload after failure clears the error and loads items`() = runTest(dispatcher) {
87
- repository.shouldFail = true
100
+ repository.failure = DomainError.Network
88
101
  val viewModel = viewModel()
89
102
 
90
103
  viewModel.state.test {
91
- assertTrue(awaitItem().isLoading)
92
- assertNotNull(awaitItem().errorMessage, "first load should fail")
104
+ assertEquals(ContentUiState.Loading, awaitItem())
105
+ assertIs<ContentUiState.Error>(awaitItem(), "first load should fail")
93
106
 
94
- repository.shouldFail = false
107
+ repository.failure = null
95
108
  repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
96
109
  viewModel.load()
97
110
 
98
- assertTrue(awaitItem().isLoading, "reload should show loading again")
99
- val recovered = awaitItem()
100
- assertNull(recovered.errorMessage)
101
- assertEquals(listOf("Recovered"), recovered.items.map { it.title })
111
+ assertEquals(ContentUiState.Loading, awaitItem(), "reload should show loading again")
112
+ val recovered = assertIs<ContentUiState.Content<List<Item>>>(awaitItem())
113
+ assertEquals(listOf("Recovered"), recovered.data.map { it.title })
102
114
  }
103
115
  }
104
116
  }
@@ -1,29 +1,33 @@
1
1
  package __PACKAGE__.testing.fakes
2
2
 
3
+ import __PACKAGE__.domain.model.DomainError
3
4
  import __PACKAGE__.domain.model.Item
4
5
  import __PACKAGE__.domain.repository.ItemRepository
6
+ import __PACKAGE__.domain.result.AppResult
5
7
 
6
8
  /**
7
9
  * Hand-written fake — the template's testing convention (no mocking frameworks: they are
8
10
  * JVM-only in KMP, and interface-driven fakes keep the architecture honest).
9
11
  *
10
12
  * The pattern every fake follows:
11
- * - configurable behavior (`items`, `shouldFail`) so a test arranges its scenario,
13
+ * - configurable behavior (`items`, `failure`) so a test arranges its scenario,
12
14
  * - recorded interactions (`getItemsCallCount`) so a test can assert usage,
13
15
  * - implements the DOMAIN interface, never a concrete data source.
16
+ *
17
+ * Failures are arranged as typed [DomainError] KINDS, mirroring the real contract — the
18
+ * fake returns [AppResult.Failure]; it never throws (repositories don't, per ARCH-06).
14
19
  */
15
20
  class FakeItemRepository : ItemRepository {
16
21
 
17
22
  var items: List<Item> = emptyList()
18
- var shouldFail: Boolean = false
19
- var failureMessage: String = "fake failure"
23
+ var failure: DomainError? = null
20
24
 
21
25
  var getItemsCallCount: Int = 0
22
26
  private set
23
27
 
24
- override suspend fun getItems(): List<Item> {
28
+ override suspend fun getItems(): AppResult<List<Item>> {
25
29
  getItemsCallCount++
26
- if (shouldFail) throw IllegalStateException(failureMessage)
27
- return items
30
+ failure?.let { return AppResult.Failure(it) }
31
+ return AppResult.Success(items)
28
32
  }
29
33
  }
@@ -0,0 +1,269 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.foundation.background
4
+ import androidx.compose.foundation.layout.Arrangement
5
+ import androidx.compose.foundation.layout.Box
6
+ import androidx.compose.foundation.layout.BoxScope
7
+ import androidx.compose.foundation.layout.Column
8
+ import androidx.compose.foundation.layout.ColumnScope
9
+ import androidx.compose.foundation.layout.fillMaxSize
10
+ import androidx.compose.foundation.layout.height
11
+ import androidx.compose.foundation.layout.padding
12
+ import androidx.compose.material.icons.Icons
13
+ import androidx.compose.material.icons.filled.Home
14
+ import androidx.compose.material.icons.filled.Person
15
+ import androidx.compose.material3.Icon
16
+ import androidx.compose.material3.Text
17
+ import androidx.compose.runtime.Composable
18
+ import androidx.compose.ui.Alignment
19
+ import androidx.compose.ui.Modifier
20
+ import androidx.compose.ui.semantics.semantics
21
+ import androidx.compose.ui.semantics.testTag
22
+ import androidx.compose.ui.unit.dp
23
+ import __PACKAGE__.presentation.components.AppBottomBar
24
+ import __PACKAGE__.presentation.components.AppHeader
25
+ import __PACKAGE__.presentation.components.AppPrimaryButton
26
+ import __PACKAGE__.presentation.components.AppTextButton
27
+ import __PACKAGE__.presentation.components.BaseScreen
28
+ import __PACKAGE__.presentation.components.ContentStateContainer
29
+ import __PACKAGE__.presentation.components.ContentStateDefaults
30
+ import __PACKAGE__.presentation.components.ContentUiState
31
+ import __PACKAGE__.presentation.components.EmptyState
32
+ import __PACKAGE__.presentation.components.ErrorState
33
+ import __PACKAGE__.presentation.components.ListItemCard
34
+ import __PACKAGE__.presentation.components.ListItemSkeleton
35
+ import __PACKAGE__.presentation.components.NavItem
36
+ import __PACKAGE__.presentation.components.ScreenColumn
37
+ import __PACKAGE__.presentation.navigation.AppTab
38
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
39
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
40
+
41
+ /**
42
+ * Component stories — one preview-registry entry per `@Composable` in
43
+ * `presentation/components` (the Storybook analog at component granularity).
44
+ * Each story renders the component in isolation on a plain tokened surface;
45
+ * a multi-variant component stacks its variants in ONE render. Ids follow
46
+ * `component.<kebab-case-of-composable-name>` (`AppHeader` →
47
+ * `component.app-header`), derivable mechanically from the name — the
48
+ * verify lane's `componentStories` step (qa/lib/component-stories.mjs)
49
+ * enforces exactly one story per component. The console excludes
50
+ * `component.*` entries from the Screens grid and shows each render at the
51
+ * top of that component's Components-page entry instead.
52
+ *
53
+ * These are preview-surface code (desktopMain), not production API: sample
54
+ * args only, tokens for every design value, testTags on every interactive
55
+ * node — a story meets the same bar the screens do.
56
+ */
57
+ fun componentStories(): List<ScreenPreview> = listOf(
58
+ // Structure: the containers a screen roots itself in.
59
+ story("component.screen-column", "ScreenColumn") {
60
+ ScreenColumn(screenTag = "story") {
61
+ Text("ScreenColumn owns the tagged root and the PaddingPage inset.")
62
+ Text("Children stack vertically; scrollable = true adds scrolling.")
63
+ }
64
+ },
65
+ story("component.base-screen", "BaseScreen") {
66
+ BaseScreen { _ ->
67
+ Text(
68
+ "BaseScreen owns the status/navigation-bar insets; body content is safe with zero ceremony.",
69
+ modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingPage),
70
+ )
71
+ }
72
+ },
73
+ // Header and navigation.
74
+ variantsStory("component.app-header", "AppHeader") {
75
+ AppHeader(title = "Screen title", screenTag = "story")
76
+ AppHeader(
77
+ title = "With back and action",
78
+ screenTag = "story_nav",
79
+ onBack = {},
80
+ actions = {
81
+ AppTextButton(
82
+ text = "Action",
83
+ onClick = {},
84
+ modifier = Modifier.semantics { testTag = "story_header_action" },
85
+ )
86
+ },
87
+ )
88
+ },
89
+ story("component.app-bottom-bar", "AppBottomBar") {
90
+ AppBottomBar(
91
+ tabs = listOf(
92
+ AppTab("Home", Icons.Filled.Home) {},
93
+ AppTab("Profile", Icons.Filled.Person) {},
94
+ ),
95
+ selectedIndex = 0,
96
+ onSelect = {},
97
+ modifier = Modifier.align(Alignment.BottomCenter),
98
+ )
99
+ },
100
+ variantsStory("component.nav-item", "NavItem") {
101
+ NavItem(label = "Selected", selected = true, onClick = {}) {
102
+ Icon(
103
+ Icons.Filled.Home,
104
+ contentDescription = "Selected",
105
+ tint = __THEME_PREFIX__Colors.Primary,
106
+ )
107
+ }
108
+ NavItem(label = "Unselected", selected = false, onClick = {}) {
109
+ Icon(
110
+ Icons.Filled.Person,
111
+ contentDescription = "Unselected",
112
+ tint = __THEME_PREFIX__Colors.OnSurfaceVariant,
113
+ )
114
+ }
115
+ },
116
+ // Buttons.
117
+ variantsStory("component.app-primary-button", "AppPrimaryButton") {
118
+ AppPrimaryButton(
119
+ text = "Primary",
120
+ onClick = {},
121
+ modifier = Modifier.semantics { testTag = "story_primary" },
122
+ )
123
+ AppPrimaryButton(
124
+ text = "Primary — disabled",
125
+ onClick = {},
126
+ enabled = false,
127
+ modifier = Modifier.semantics { testTag = "story_primary_disabled" },
128
+ )
129
+ },
130
+ variantsStory("component.app-text-button", "AppTextButton") {
131
+ AppTextButton(
132
+ text = "Text button",
133
+ onClick = {},
134
+ modifier = Modifier.semantics { testTag = "story_text" },
135
+ )
136
+ AppTextButton(
137
+ text = "Text button — disabled",
138
+ onClick = {},
139
+ enabled = false,
140
+ modifier = Modifier.semantics { testTag = "story_text_disabled" },
141
+ )
142
+ },
143
+ // The four-state contract: all four arms of the container, stacked.
144
+ variantsStory("component.content-state-container", "ContentStateContainer") {
145
+ ContentStateContainer<List<String>>(
146
+ state = ContentUiState.Loading,
147
+ screenTag = "story_loading",
148
+ modifier = Modifier.height(180.dp),
149
+ ) { }
150
+ ContentStateContainer<List<String>>(
151
+ state = ContentUiState.Empty,
152
+ screenTag = "story_empty",
153
+ modifier = Modifier.height(180.dp),
154
+ ) { }
155
+ ContentStateContainer<List<String>>(
156
+ state = ContentUiState.Error("Something went wrong."),
157
+ screenTag = "story_error",
158
+ onRetry = {},
159
+ modifier = Modifier.height(180.dp),
160
+ ) { }
161
+ ContentStateContainer(
162
+ state = ContentUiState.Content(listOf("First item", "Second item")),
163
+ screenTag = "story_content",
164
+ modifier = Modifier.height(180.dp),
165
+ ) { data ->
166
+ Column(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
167
+ data.forEachIndexed { i, title ->
168
+ ListItemCard(
169
+ title = title,
170
+ onClick = {},
171
+ modifier = Modifier.semantics { testTag = "story_state_item_$i" },
172
+ )
173
+ }
174
+ }
175
+ }
176
+ },
177
+ variantsStory("component.empty-state", "EmptyState") {
178
+ EmptyState(
179
+ screenTag = "story",
180
+ modifier = Modifier.height(220.dp),
181
+ body = "Items you add will show up here.",
182
+ action = {
183
+ AppTextButton(
184
+ text = "Add an item",
185
+ onClick = {},
186
+ modifier = Modifier.semantics { testTag = "story_empty_action" },
187
+ )
188
+ },
189
+ )
190
+ },
191
+ variantsStory("component.error-state", "ErrorState") {
192
+ ErrorState(
193
+ message = "Something went wrong.",
194
+ screenTag = "story",
195
+ onRetry = {},
196
+ modifier = Modifier.height(220.dp),
197
+ )
198
+ },
199
+ // List vocabulary.
200
+ variantsStory("component.list-item-card", "ListItemCard") {
201
+ ListItemCard(
202
+ title = "Title only",
203
+ onClick = {},
204
+ modifier = Modifier.semantics { testTag = "story_item_1" },
205
+ )
206
+ ListItemCard(
207
+ title = "With subtitle",
208
+ subtitle = "Secondary line",
209
+ onClick = {},
210
+ modifier = Modifier.semantics { testTag = "story_item_2" },
211
+ )
212
+ ListItemCard(
213
+ title = "With a leading slot",
214
+ subtitle = "Leading content precedes the text column",
215
+ onClick = {},
216
+ modifier = Modifier.semantics { testTag = "story_item_3" },
217
+ leading = { Icon(Icons.Filled.Person, contentDescription = null) },
218
+ )
219
+ },
220
+ story("component.list-skeleton", "ContentStateDefaults.ListSkeleton") {
221
+ Box(Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage)) {
222
+ ContentStateDefaults.ListSkeleton(screenTag = "story")
223
+ }
224
+ },
225
+ variantsStory("component.list-item-skeleton", "ListItemSkeleton") {
226
+ ListItemSkeleton()
227
+ },
228
+ story("component.spinner", "ContentStateDefaults.Spinner") {
229
+ ContentStateDefaults.Spinner(screenTag = "story")
230
+ },
231
+ )
232
+
233
+ /**
234
+ * `component.<kebab-name>` entry hosting [content] on the plain story surface.
235
+ * The id is passed as a full literal (never concatenated) so the lane's parity
236
+ * gate (qa/lib/component-stories.mjs) and a plain grep both find it.
237
+ */
238
+ private fun story(
239
+ id: String,
240
+ title: String,
241
+ content: @Composable BoxScope.() -> Unit,
242
+ ): ScreenPreview = ScreenPreview(id, "$title — component story") {
243
+ StoryHost(content)
244
+ }
245
+
246
+ /** Stacked-variants flavor: the story surface with a padded, token-gapped column. */
247
+ private fun variantsStory(
248
+ id: String,
249
+ title: String,
250
+ content: @Composable ColumnScope.() -> Unit,
251
+ ): ScreenPreview = story(id, title) {
252
+ Column(
253
+ modifier = Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage),
254
+ verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
255
+ content = content,
256
+ )
257
+ }
258
+
259
+ /**
260
+ * The plain tokened surface every story renders on: theme background, nothing
261
+ * else — the component is the only subject. Internal (not private) so the
262
+ * generated registry can host the PlaceholderScreen story on custom-tab
263
+ * scaffolds (PlaceholderScreen ships only when a configured tab has no
264
+ * feature yet, so its story rides PreviewRegistry.kt, not this file).
265
+ */
266
+ @Composable
267
+ internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
268
+ Box(Modifier.fillMaxSize().background(__THEME_PREFIX__Colors.Background)) { content() }
269
+ }
@@ -4,12 +4,19 @@ import androidx.compose.foundation.layout.Box
4
4
  import androidx.compose.foundation.layout.fillMaxSize
5
5
  import androidx.compose.runtime.Composable
6
6
  import androidx.compose.ui.Modifier
7
+ import __PACKAGE__.domain.model.DomainError
8
+ import __PACKAGE__.domain.model.Item
9
+ import __PACKAGE__.domain.repository.ItemRepository
10
+ import __PACKAGE__.domain.result.AppResult
11
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
7
12
  import __PACKAGE__.presentation.components.BaseScreen
8
13
  import __PACKAGE__.presentation.home.DetailScreen
9
14
  import __PACKAGE__.presentation.home.HomeScreen
15
+ import __PACKAGE__.presentation.home.HomeViewModel
10
16
  import __PACKAGE__.presentation.navigation.AppShell
11
17
  import __PACKAGE__.presentation.navigation.appTabs
12
18
  import __PACKAGE__.presentation.profile.ProfileScreen
19
+ import kotlinx.coroutines.awaitCancellation
13
20
 
14
21
  /**
15
22
  * One previewable screen: a stable [id] (the `-Pscreen=` selector and output directory
@@ -29,6 +36,11 @@ import __PACKAGE__.presentation.profile.ProfileScreen
29
36
  * preview-only fakes behind its usual parameters). Every entry renders the same way
30
37
  * (gallery card, `-Pscreen=` selector, golden baseline), so loading/empty/error states
31
38
  * sit side by side with the default seeded state.
39
+ *
40
+ * Component stories (`component.<kebab-name>` ids, ComponentStories.kt) are appended
41
+ * below — one isolated render per `presentation/components` composable. The console
42
+ * keeps them out of the Screens grid and shows each at the top of its Components-page
43
+ * entry; the verify lane's `componentStories` step enforces one story per component.
32
44
  */
33
45
  data class ScreenPreview(
34
46
  val id: String,
@@ -49,8 +61,21 @@ fun previewRegistry(): List<ScreenPreview> = listOf(
49
61
  ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
50
62
  ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
51
63
  ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
64
+ // State variants (§6.5, component-system-deep-dive.md): the same ContentUiState arms
65
+ // ContentStateContainer dispatches on, forced via a preview-only repository — the
66
+ // console's genesis workbench and the golden baselines get loading/empty/error as
67
+ // first-class screens beside the default seeded "home" entry.
68
+ ScreenPreview("home@loading", "Home — loading") {
69
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { awaitCancellation() }) }
70
+ },
71
+ ScreenPreview("home@empty", "Home — empty") {
72
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Success(emptyList()) }) }
73
+ },
74
+ ScreenPreview("home@error", "Home — error") {
75
+ TabHost { HomeScreen(onItemClick = {}, viewModel = previewHomeViewModel { AppResult.Failure(DomainError.Network) }) }
76
+ },
52
77
  // cmp:anchor preview-registry
53
- )
78
+ ) + componentStories()
54
79
 
55
80
  /**
56
81
  * Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
@@ -62,3 +87,14 @@ private fun TabHost(content: @Composable () -> Unit) {
62
87
  Box(Modifier.fillMaxSize()) { content() }
63
88
  }
64
89
  }
90
+
91
+ /**
92
+ * Forces one `ContentUiState` arm on a real [HomeViewModel] for the state-variant previews
93
+ * above. `desktopMain` cannot depend on `commonTest`'s `FakeItemRepository` (test sources
94
+ * never leak into main), so this is a minimal, self-contained equivalent — the real
95
+ * ViewModel and screen render unmodified, only the repository result is forced.
96
+ */
97
+ private fun previewHomeViewModel(result: suspend () -> AppResult<List<Item>>): HomeViewModel =
98
+ HomeViewModel(GetItemsUseCase(object : ItemRepository {
99
+ override suspend fun getItems(): AppResult<List<Item>> = result()
100
+ }))