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
@@ -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,300 @@
1
+ package __PACKAGE__.inspector
2
+
3
+ import androidx.compose.foundation.layout.Arrangement
4
+ import androidx.compose.foundation.layout.Box
5
+ import androidx.compose.foundation.layout.BoxScope
6
+ import androidx.compose.foundation.layout.Column
7
+ import androidx.compose.foundation.layout.ColumnScope
8
+ import androidx.compose.foundation.layout.fillMaxSize
9
+ import androidx.compose.foundation.layout.height
10
+ import androidx.compose.foundation.layout.padding
11
+ import androidx.compose.material.icons.Icons
12
+ import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.Surface
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.semantics.semantics
22
+ import androidx.compose.ui.semantics.testTag
23
+ import androidx.compose.ui.unit.dp
24
+ import __PACKAGE__.presentation.components.AppBottomBar
25
+ import __PACKAGE__.presentation.components.AppHeader
26
+ import __PACKAGE__.presentation.components.AppIconButton
27
+ import __PACKAGE__.presentation.components.AppPrimaryButton
28
+ import __PACKAGE__.presentation.components.AppTextButton
29
+ import __PACKAGE__.presentation.components.BaseScreen
30
+ import __PACKAGE__.presentation.components.ContentStateContainer
31
+ import __PACKAGE__.presentation.components.ContentStateDefaults
32
+ import __PACKAGE__.presentation.components.ContentUiState
33
+ import __PACKAGE__.presentation.components.EmptyState
34
+ import __PACKAGE__.presentation.components.ErrorState
35
+ import __PACKAGE__.presentation.components.ListItemCard
36
+ import __PACKAGE__.presentation.components.ListItemSkeleton
37
+ import __PACKAGE__.presentation.components.NavItem
38
+ import __PACKAGE__.presentation.components.ScreenColumn
39
+ import __PACKAGE__.presentation.navigation.AppTab
40
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
41
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
42
+
43
+ /**
44
+ * Component stories — one preview-registry entry per `@Composable` in
45
+ * `presentation/components` (the Storybook analog at component granularity).
46
+ * Each story renders the component in isolation on a plain tokened surface;
47
+ * a multi-variant component stacks its variants in ONE render. Ids follow
48
+ * `component.<kebab-case-of-composable-name>` (`AppHeader` →
49
+ * `component.app-header`), derivable mechanically from the name — the
50
+ * verify lane's `componentStories` step (qa/lib/component-stories.mjs)
51
+ * enforces exactly one story per component. The console excludes
52
+ * `component.*` entries from the Screens grid and shows each render at the
53
+ * top of that component's Components-page entry instead.
54
+ *
55
+ * These are preview-surface code (desktopMain), not production API: sample
56
+ * args only, tokens for every design value, testTags on every interactive
57
+ * node — a story meets the same bar the screens do.
58
+ */
59
+ fun componentStories(): List<ScreenPreview> = listOf(
60
+ // Structure: the containers a screen roots itself in.
61
+ story("component.screen-column", "ScreenColumn") {
62
+ ScreenColumn(screenTag = "story") {
63
+ Text("ScreenColumn owns the tagged root and the PaddingPage inset.")
64
+ Text("Children stack vertically; scrollable = true adds scrolling.")
65
+ }
66
+ },
67
+ story("component.base-screen", "BaseScreen") {
68
+ BaseScreen { _ ->
69
+ Text(
70
+ "BaseScreen owns the status/navigation-bar insets; body content is safe with zero ceremony.",
71
+ modifier = Modifier.padding(__THEME_PREFIX__Tokens.PaddingPage),
72
+ )
73
+ }
74
+ },
75
+ // Header and navigation.
76
+ variantsStory("component.app-header", "AppHeader") {
77
+ AppHeader(title = "Screen title", screenTag = "story")
78
+ AppHeader(
79
+ title = "With back and action",
80
+ screenTag = "story_nav",
81
+ onBack = {},
82
+ actions = {
83
+ AppTextButton(
84
+ text = "Action",
85
+ onClick = {},
86
+ modifier = Modifier.semantics { testTag = "story_header_action" },
87
+ )
88
+ },
89
+ )
90
+ },
91
+ story("component.app-bottom-bar", "AppBottomBar") {
92
+ AppBottomBar(
93
+ tabs = listOf(
94
+ AppTab("Home", Icons.Filled.Home) {},
95
+ AppTab("Profile", Icons.Filled.Person) {},
96
+ ),
97
+ selectedIndex = 0,
98
+ onSelect = {},
99
+ modifier = Modifier.align(Alignment.BottomCenter),
100
+ )
101
+ },
102
+ variantsStory("component.nav-item", "NavItem") {
103
+ NavItem(label = "Selected", selected = true, onClick = {}) {
104
+ Icon(
105
+ Icons.Filled.Home,
106
+ contentDescription = "Selected",
107
+ tint = __THEME_PREFIX__Colors.Primary,
108
+ )
109
+ }
110
+ NavItem(label = "Unselected", selected = false, onClick = {}) {
111
+ Icon(
112
+ Icons.Filled.Person,
113
+ contentDescription = "Unselected",
114
+ tint = __THEME_PREFIX__Colors.OnSurfaceVariant,
115
+ )
116
+ }
117
+ },
118
+ // Buttons.
119
+ variantsStory("component.app-primary-button", "AppPrimaryButton") {
120
+ AppPrimaryButton(
121
+ text = "Primary",
122
+ onClick = {},
123
+ modifier = Modifier.semantics { testTag = "story_primary" },
124
+ )
125
+ AppPrimaryButton(
126
+ text = "Primary — disabled",
127
+ onClick = {},
128
+ enabled = false,
129
+ modifier = Modifier.semantics { testTag = "story_primary_disabled" },
130
+ )
131
+ },
132
+ variantsStory("component.app-text-button", "AppTextButton") {
133
+ AppTextButton(
134
+ text = "Text button",
135
+ onClick = {},
136
+ modifier = Modifier.semantics { testTag = "story_text" },
137
+ )
138
+ AppTextButton(
139
+ text = "Text button — disabled",
140
+ onClick = {},
141
+ enabled = false,
142
+ modifier = Modifier.semantics { testTag = "story_text_disabled" },
143
+ )
144
+ },
145
+ variantsStory("component.app-icon-button", "AppIconButton") {
146
+ AppIconButton(
147
+ icon = Icons.AutoMirrored.Filled.ArrowBack,
148
+ contentDescription = "Back",
149
+ onClick = {},
150
+ modifier = Modifier.semantics { testTag = "story_icon_button" },
151
+ )
152
+ AppIconButton(
153
+ icon = Icons.AutoMirrored.Filled.ArrowBack,
154
+ contentDescription = "Back — disabled",
155
+ onClick = {},
156
+ enabled = false,
157
+ modifier = Modifier.semantics { testTag = "story_icon_button_disabled" },
158
+ )
159
+ },
160
+ // The four-state contract: all four arms of the container, stacked.
161
+ variantsStory("component.content-state-container", "ContentStateContainer") {
162
+ ContentStateContainer<List<String>>(
163
+ state = ContentUiState.Loading,
164
+ screenTag = "story_loading",
165
+ modifier = Modifier.height(180.dp),
166
+ ) { }
167
+ ContentStateContainer<List<String>>(
168
+ state = ContentUiState.Empty,
169
+ screenTag = "story_empty",
170
+ modifier = Modifier.height(180.dp),
171
+ ) { }
172
+ ContentStateContainer<List<String>>(
173
+ state = ContentUiState.Error("Something went wrong."),
174
+ screenTag = "story_error",
175
+ onRetry = {},
176
+ modifier = Modifier.height(180.dp),
177
+ ) { }
178
+ ContentStateContainer(
179
+ state = ContentUiState.Content(listOf("First item", "Second item")),
180
+ screenTag = "story_content",
181
+ modifier = Modifier.height(180.dp),
182
+ ) { data ->
183
+ Column(verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard)) {
184
+ data.forEachIndexed { i, title ->
185
+ ListItemCard(
186
+ title = title,
187
+ onClick = {},
188
+ modifier = Modifier.semantics { testTag = "story_state_item_$i" },
189
+ )
190
+ }
191
+ }
192
+ }
193
+ },
194
+ variantsStory("component.empty-state", "EmptyState") {
195
+ EmptyState(
196
+ screenTag = "story",
197
+ modifier = Modifier.height(220.dp),
198
+ body = "Items you add will show up here.",
199
+ action = {
200
+ AppTextButton(
201
+ text = "Add an item",
202
+ onClick = {},
203
+ modifier = Modifier.semantics { testTag = "story_empty_action" },
204
+ )
205
+ },
206
+ )
207
+ },
208
+ variantsStory("component.error-state", "ErrorState") {
209
+ ErrorState(
210
+ message = "Something went wrong.",
211
+ screenTag = "story",
212
+ onRetry = {},
213
+ modifier = Modifier.height(220.dp),
214
+ )
215
+ },
216
+ // List vocabulary.
217
+ variantsStory("component.list-item-card", "ListItemCard") {
218
+ ListItemCard(
219
+ title = "Title only",
220
+ onClick = {},
221
+ modifier = Modifier.semantics { testTag = "story_item_1" },
222
+ )
223
+ ListItemCard(
224
+ title = "With subtitle",
225
+ subtitle = "Secondary line",
226
+ onClick = {},
227
+ modifier = Modifier.semantics { testTag = "story_item_2" },
228
+ )
229
+ ListItemCard(
230
+ title = "With a leading slot",
231
+ subtitle = "Leading content precedes the text column",
232
+ onClick = {},
233
+ modifier = Modifier.semantics { testTag = "story_item_3" },
234
+ leading = { Icon(Icons.Filled.Person, contentDescription = null) },
235
+ )
236
+ },
237
+ story("component.list-skeleton", "ContentStateDefaults.ListSkeleton") {
238
+ Box(Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage)) {
239
+ ContentStateDefaults.ListSkeleton(screenTag = "story")
240
+ }
241
+ },
242
+ variantsStory("component.list-item-skeleton", "ListItemSkeleton") {
243
+ ListItemSkeleton()
244
+ },
245
+ story("component.spinner", "ContentStateDefaults.Spinner") {
246
+ ContentStateDefaults.Spinner(screenTag = "story")
247
+ },
248
+ )
249
+
250
+ /**
251
+ * `component.<kebab-name>` entry hosting [content] on the plain story surface.
252
+ * The id is passed as a full literal (never concatenated) so the lane's parity
253
+ * gate (qa/lib/component-stories.mjs) and a plain grep both find it.
254
+ */
255
+ private fun story(
256
+ id: String,
257
+ title: String,
258
+ content: @Composable BoxScope.() -> Unit,
259
+ ): ScreenPreview = ScreenPreview(id, "$title — component story") {
260
+ StoryHost(content)
261
+ }
262
+
263
+ /** Stacked-variants flavor: the story surface with a padded, token-gapped column. */
264
+ private fun variantsStory(
265
+ id: String,
266
+ title: String,
267
+ content: @Composable ColumnScope.() -> Unit,
268
+ ): ScreenPreview = story(id, title) {
269
+ Column(
270
+ modifier = Modifier.fillMaxSize().padding(__THEME_PREFIX__Tokens.PaddingPage),
271
+ verticalArrangement = Arrangement.spacedBy(__THEME_PREFIX__Tokens.GapCard),
272
+ content = content,
273
+ )
274
+ }
275
+
276
+ /**
277
+ * The plain tokened surface every story renders on: theme background, nothing
278
+ * else — the component is the only subject. Internal (not private) so the
279
+ * generated registry can host the PlaceholderScreen story on custom-tab
280
+ * scaffolds (PlaceholderScreen ships only when a configured tab has no
281
+ * feature yet, so its story rides PreviewRegistry.kt, not this file).
282
+ *
283
+ * A `Surface`, not a bare `Box`: real screens root in [BaseScreen]'s `Scaffold`, which
284
+ * provides `LocalContentColor` to everything below it. A Box only PAINTS a background —
285
+ * it supplies no content color — so any component that correctly inherits one (an
286
+ * [AppIconButton] tint, a bare `Text`) fell back to `LocalContentColor`'s black default
287
+ * and rendered black-on-near-black. The story read as an empty rectangle while the
288
+ * component was in fact drawing perfectly. Stories must sit in the same content-color
289
+ * context as the screens they document, or they document a lie.
290
+ */
291
+ @Composable
292
+ internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
293
+ Surface(
294
+ modifier = Modifier.fillMaxSize(),
295
+ color = __THEME_PREFIX__Colors.Background,
296
+ contentColor = __THEME_PREFIX__Colors.OnSurface,
297
+ ) {
298
+ Box(Modifier.fillMaxSize()) { content() }
299
+ }
300
+ }
@@ -69,6 +69,11 @@ fun main(args: Array<String>) {
69
69
  put("ok", JsonPrimitive(true))
70
70
  put("pid", JsonPrimitive(ProcessHandle.current().pid()))
71
71
  put("port", JsonPrimitive(port))
72
+ // WHICH project this daemon serves. The port is machine-global, so a
73
+ // preview service that finds a healthy daemon here has no other way to
74
+ // tell "my project's daemon" from "another checkout's daemon on the
75
+ // same port" — and adopting the wrong one renders another app's screens.
76
+ put("previewsDir", JsonPrimitive(outRoot.absolutePath))
72
77
  put("reloadCount", JsonPrimitive(reloadCount.get()))
73
78
  put("reloadErrors", JsonPrimitive(reloadErrors.get()))
74
79
  put("reloadHooked", JsonPrimitive(reloadHooked))
@@ -25,12 +25,16 @@ import __PACKAGE__.di.appModules
25
25
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
26
26
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Theme
27
27
  import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
28
+ import __PACKAGE__.presentation.theme.__THEME_PREFIX__TypeRamp
28
29
  import kotlinx.serialization.json.Json
29
30
  import kotlinx.serialization.json.JsonElement
31
+ import kotlinx.serialization.json.JsonNull
30
32
  import kotlinx.serialization.json.JsonPrimitive
31
33
  import kotlinx.serialization.json.buildJsonArray
32
34
  import kotlinx.serialization.json.buildJsonObject
35
+ import org.jetbrains.skia.Bitmap
33
36
  import org.jetbrains.skia.EncodedImageFormat
37
+ import org.jetbrains.skia.Image
34
38
  import org.koin.core.context.startKoin
35
39
  import org.koin.dsl.module
36
40
  import java.io.File
@@ -189,7 +193,8 @@ internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
189
193
  }
190
194
  iterations++
191
195
  }
192
- val data = image.encodeToData(EncodedImageFormat.PNG)
196
+ val framed = if (isComponentStory(entry.id)) cropToContent(image, scale) else image
197
+ val data = framed.encodeToData(EncodedImageFormat.PNG)
193
198
  ?: error("Skia failed to encode ${entry.id} as PNG")
194
199
  outFile.writeBytes(data.bytes)
195
200
  } finally {
@@ -197,6 +202,74 @@ internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
197
202
  }
198
203
  }
199
204
 
205
+ /**
206
+ * Component stories are the ONLY previews cropped. A screen's frame is part of
207
+ * what is being reviewed (insets, bottom bar, where content sits on the page),
208
+ * so screens keep the full phone viewport. A component story is the opposite:
209
+ * a 48 dp button rendered on an 891 dp canvas is 3% component and 97% empty
210
+ * background, which is what made the Components gallery read as broken.
211
+ */
212
+ private fun isComponentStory(id: String) = id.startsWith("component.")
213
+
214
+ /**
215
+ * Tightest rectangle whose pixels differ from the frame's own background,
216
+ * plus one page-padding gutter. Measured from the PIXELS, not the semantics
217
+ * tree — decoration with no semantics (a ring, a shimmer, a divider) is part
218
+ * of the component and must not be cropped off.
219
+ *
220
+ * The background reference is the top-left pixel: [StoryHost] paints the whole
221
+ * canvas in the Background token before drawing, so that corner is background
222
+ * by construction. Two honest fallbacks, never a wrong crop:
223
+ * - nothing differs (a story whose content IS the background color) -> the
224
+ * full frame, uncropped;
225
+ * - a degenerate box (under 8 px on a side) -> the full frame too.
226
+ * Cropping is lossless: the kept pixels are the rendered pixels, untouched.
227
+ */
228
+ private fun cropToContent(image: Image, scale: Float): Image {
229
+ val bitmap = Bitmap.makeFromImage(image)
230
+ val info = bitmap.imageInfo
231
+ val w = info.width
232
+ val h = info.height
233
+ val bpp = info.bytesPerPixel
234
+ if (w <= 0 || h <= 0 || bpp <= 0) return image
235
+ // One bulk read, then scan in memory — getColor(x, y) per pixel would be
236
+ // ~1.5M native calls per story.
237
+ val rowBytes = w * bpp
238
+ val pixels = bitmap.readPixels(info, rowBytes, 0, 0) ?: return image
239
+ fun samePixelAsOrigin(offset: Int): Boolean {
240
+ for (b in 0 until bpp) if (pixels[offset + b] != pixels[b]) return false
241
+ return true
242
+ }
243
+ var minX = w
244
+ var minY = h
245
+ var maxX = -1
246
+ var maxY = -1
247
+ for (y in 0 until h) {
248
+ val row = y * rowBytes
249
+ for (x in 0 until w) {
250
+ if (!samePixelAsOrigin(row + x * bpp)) {
251
+ if (x < minX) minX = x
252
+ if (y < minY) minY = y
253
+ if (x > maxX) maxX = x
254
+ if (y > maxY) maxY = y
255
+ }
256
+ }
257
+ }
258
+ if (maxX < 0 || maxY < 0) return image
259
+ val gutter = (__THEME_PREFIX__Tokens.PaddingPage.value * scale).toInt().coerceAtLeast(1)
260
+ val left = (minX - gutter).coerceAtLeast(0)
261
+ val top = (minY - gutter).coerceAtLeast(0)
262
+ val right = (maxX + 1 + gutter).coerceAtMost(w)
263
+ val bottom = (maxY + 1 + gutter).coerceAtMost(h)
264
+ val cw = right - left
265
+ val ch = bottom - top
266
+ if (cw < 8 || ch < 8) return image
267
+ val croppedInfo = info.withWidthHeight(cw, ch)
268
+ val croppedRowBytes = cw * bpp
269
+ val cropped = bitmap.readPixels(croppedInfo, croppedRowBytes, left, top) ?: return image
270
+ return Image.makeRaster(croppedInfo, cropped, croppedRowBytes)
271
+ }
272
+
200
273
  /** The declared design-system catalog — generated FROM the theme objects, so it can't drift. */
201
274
  internal fun designSystemCatalog(): String {
202
275
  val pretty = Json { prettyPrint = true }
@@ -237,6 +310,23 @@ internal fun designSystemCatalog(): String {
237
310
  put("RadiusModal", dp(__THEME_PREFIX__Tokens.RadiusModal))
238
311
  put("RadiusInput", dp(__THEME_PREFIX__Tokens.RadiusInput))
239
312
  })
313
+ // The type ramp, published from the SAME data the Typography factory
314
+ // builds its styles from (presentation/theme/Typography.kt) — the
315
+ // console's Design language page renders the ramp from this block, so a
316
+ // ramp that ships and a ramp that is documented cannot diverge. Font
317
+ // family is deliberately absent: it resolves through @Composable Font()
318
+ // and is not derivable here, and a guessed name would be a fabrication.
319
+ put("typography", buildJsonArray {
320
+ __THEME_PREFIX__TypeRamp.forEach { spec ->
321
+ add(buildJsonObject {
322
+ put("name", JsonPrimitive(spec.name))
323
+ put("weight", JsonPrimitive(spec.weight))
324
+ put("size", JsonPrimitive("${spec.sizeSp}sp"))
325
+ put("lineHeight", JsonPrimitive("${spec.lineHeightSp}sp"))
326
+ put("tracking", spec.tracking?.let { JsonPrimitive("${it}sp") } ?: JsonNull)
327
+ })
328
+ }
329
+ })
240
330
  }
241
331
  return pretty.encodeToString(JsonElement.serializer(), doc)
242
332
  }
@@ -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
+ }))