create-cmp-cli 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +72 -11
  2. package/llms.txt +6 -2
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +8 -1
  5. package/src/lib/adr-seed.mjs +178 -0
  6. package/src/lib/registry.mjs +15 -2
  7. package/src/lib/tabs.mjs +91 -4
  8. package/src/lib/upgrade.mjs +49 -5
  9. package/src/scaffold.mjs +52 -1
  10. package/src/versions/candidates.json +4 -0
  11. package/src/versions/registry.json +88 -0
  12. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  13. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  14. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  15. package/template/.githooks/pre-push +24 -0
  16. package/template/CLAUDE.md +196 -48
  17. package/template/README.md +23 -27
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  20. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +69 -2
  21. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  22. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +54 -0
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +13 -0
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +21 -0
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +269 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  55. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +207 -15
  56. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  57. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  58. package/template/docs/ARCHITECTURE.md +317 -34
  59. package/template/docs/TESTING.md +6 -5
  60. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  61. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  62. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  63. package/template/qa/approvals.json +42 -0
  64. package/template/qa/approve.mjs +139 -0
  65. package/template/qa/arch-doc.mjs +69 -0
  66. package/template/qa/comment.mjs +76 -0
  67. package/template/qa/comments.json +4 -0
  68. package/template/qa/golden/home.json +3 -3
  69. package/template/qa/lib/approvals.mjs +806 -0
  70. package/template/qa/lib/arch-doc.mjs +451 -0
  71. package/template/qa/lib/comments.mjs +252 -0
  72. package/template/qa/lib/component-stories.mjs +183 -0
  73. package/template/qa/lib/inputs-hash.mjs +5 -1
  74. package/template/qa/scaffold-feature.mjs +184 -67
  75. package/template/qa/setup-hooks.mjs +33 -0
  76. package/template/qa/verify.mjs +118 -9
  77. package/template/specs/app-base.spec.md +44 -7
  78. package/template/specs/home.spec.md +7 -4
  79. package/template/specs/intent.md +50 -0
@@ -0,0 +1,84 @@
1
+ package __PACKAGE__.conformance
2
+
3
+ import androidx.compose.material3.MaterialTheme
4
+ import androidx.compose.ui.test.ExperimentalTestApi
5
+ import androidx.compose.ui.test.assertHeightIsAtLeast
6
+ import androidx.compose.ui.test.assertWidthIsAtLeast
7
+ import androidx.compose.ui.test.hasTestTag
8
+ import androidx.compose.ui.test.hasText
9
+ import androidx.compose.ui.test.onNodeWithTag
10
+ import androidx.compose.ui.test.runComposeUiTest
11
+ import androidx.compose.ui.unit.dp
12
+ import __PACKAGE__.domain.model.DomainError
13
+ import __PACKAGE__.domain.model.Item
14
+ import __PACKAGE__.domain.usecase.GetItemsUseCase
15
+ import __PACKAGE__.presentation.home.HomeScreen
16
+ import __PACKAGE__.presentation.home.HomeViewModel
17
+ import __PACKAGE__.testing.awaitNode
18
+ import __PACKAGE__.testing.fakes.FakeItemRepository
19
+ import kotlin.test.Test
20
+
21
+ /**
22
+ * The COMP clauses (`specs/app-base.spec.md`) as executable checks — the component
23
+ * vocabulary's runtime contract, proven against the exemplar screen the same way the
24
+ * ARCH/SHELL conformance gates prove the architecture. Component substructure/tag
25
+ * stability is additionally pinned by the golden-tree baseline (`qa/golden/`); this class
26
+ * proves the STATE/A11Y contract every registry consumer inherits for free.
27
+ */
28
+ @OptIn(ExperimentalTestApi::class)
29
+ class ComponentConformanceTest {
30
+
31
+ // SPEC: COMP-01
32
+ @Test
33
+ fun `a failed load is presented by ContentStateContainer with the screen-derived error tag`() = runComposeUiTest {
34
+ val repository = FakeItemRepository().apply { failure = DomainError.Network }
35
+ setContent {
36
+ MaterialTheme {
37
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
38
+ }
39
+ }
40
+ awaitNode(hasTestTag("home_error"))
41
+ }
42
+
43
+ // SPEC: COMP-01
44
+ @Test
45
+ fun `a zero-item load is presented by ContentStateContainer with the screen-derived empty tag`() = runComposeUiTest {
46
+ val repository = FakeItemRepository().apply { items = emptyList() }
47
+ setContent {
48
+ MaterialTheme {
49
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
50
+ }
51
+ }
52
+ awaitNode(hasTestTag("home_empty"))
53
+ }
54
+
55
+ // SPEC: COMP-02
56
+ @Test
57
+ fun `a recoverable error renders a retry control of at least 48dp`() = runComposeUiTest {
58
+ val repository = FakeItemRepository().apply { failure = DomainError.Network }
59
+ setContent {
60
+ MaterialTheme {
61
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
62
+ }
63
+ }
64
+ awaitNode(hasTestTag("home_retry"))
65
+ onNodeWithTag("home_retry")
66
+ .assertWidthIsAtLeast(48.dp)
67
+ .assertHeightIsAtLeast(48.dp)
68
+ }
69
+
70
+ // SPEC: COMP-03
71
+ @Test
72
+ fun `every ListItemCard row clears the 48dp minimum pointer target`() = runComposeUiTest {
73
+ val repository = FakeItemRepository().apply {
74
+ items = listOf(Item(id = "1", title = "Row", subtitle = "sub"))
75
+ }
76
+ setContent {
77
+ MaterialTheme {
78
+ HomeScreen(onItemClick = {}, viewModel = HomeViewModel(GetItemsUseCase(repository)))
79
+ }
80
+ }
81
+ awaitNode(hasText("Row"))
82
+ onNodeWithTag("home_item_1").assertHeightIsAtLeast(48.dp)
83
+ }
84
+ }
@@ -10,6 +10,7 @@ import androidx.compose.ui.test.onFirst
10
10
  import androidx.compose.ui.test.onNodeWithTag
11
11
  import androidx.compose.ui.test.performClick
12
12
  import androidx.compose.ui.test.runComposeUiTest
13
+ import __PACKAGE__.domain.model.DomainError
13
14
  import __PACKAGE__.domain.model.Item
14
15
  import __PACKAGE__.domain.usecase.GetItemsUseCase
15
16
  import __PACKAGE__.testing.awaitNode
@@ -47,16 +48,27 @@ class HomeScreenTest {
47
48
 
48
49
  // SPEC: HOME-03
49
50
  @Test
50
- fun `shows the error message when loading fails`() = runComposeUiTest {
51
- repository.shouldFail = true
52
- repository.failureMessage = "network down"
51
+ fun `shows presentation-mapped error copy when loading fails`() = runComposeUiTest {
52
+ repository.failure = DomainError.Network
53
53
 
54
54
  setContent {
55
55
  MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
56
56
  }
57
57
 
58
58
  awaitNode(hasTestTag("home_error"))
59
- onAllNodesWithText("network down").assertCountEquals(1)
59
+ onAllNodesWithText(DomainError.Network.toUserMessage()).assertCountEquals(1)
60
+ }
61
+
62
+ // SPEC: HOME-07
63
+ @Test
64
+ fun `shows the empty state when the repository returns no items`() = runComposeUiTest {
65
+ repository.items = emptyList()
66
+
67
+ setContent {
68
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = viewModel()) }
69
+ }
70
+
71
+ awaitNode(hasTestTag("home_empty"))
60
72
  }
61
73
 
62
74
  // SPEC: HOME-05
@@ -74,4 +86,24 @@ class HomeScreenTest {
74
86
  waitUntil(timeoutMillis = 5_000) { clickedId != null }
75
87
  kotlin.test.assertEquals("item-42", clickedId)
76
88
  }
89
+
90
+ // SPEC: HOME-04
91
+ @Test
92
+ fun `tapping retry after a failure reloads and shows recovered items`() = runComposeUiTest {
93
+ repository.failure = DomainError.Network
94
+ val vm = viewModel()
95
+
96
+ setContent {
97
+ MaterialTheme { HomeScreen(onItemClick = {}, viewModel = vm) }
98
+ }
99
+
100
+ awaitNode(hasTestTag("home_error"))
101
+ onNodeWithTag("home_retry", useUnmergedTree = true).assertExists()
102
+
103
+ repository.failure = null
104
+ repository.items = listOf(Item(id = "1", title = "Recovered", subtitle = "sub"))
105
+ onNodeWithTag("home_retry").performClick()
106
+
107
+ awaitNode(hasText("Recovered"))
108
+ }
77
109
  }
@@ -1,51 +1,334 @@
1
1
  # Architecture
2
2
 
3
- Clean Architecture, three layers, one rule: **dependencies point inward.**
3
+ > **Reading this document.** Every normative sentence below carries a tier tag.
4
+ > `[enforced: CLAUSE-ID]`: a named gate in `node qa/verify.mjs` fails the lane on violation.
5
+ > `[governed]`: the sentence lives inside a hash-bound human approval (`qa/approvals.json`);
6
+ > changing it without re-approval fails the `approvals` gate. `[advisory]`: a documented
7
+ > convention with no mechanical check yet. Every sentence is law, signed intent, or advice —
8
+ > and says which.
9
+
10
+ ## 1. Purpose & quality goals
11
+
12
+ This app's purpose, audience, and shape are recorded in [`specs/intent.md`](../specs/intent.md)
13
+ — the root brief this document, the component registry, and the exemplar feature all trace
14
+ back to. The table below is the default quality-goal set a fresh scaffold ships with. The
15
+ genesis walk's architecture conversation is where a human promotes, demotes, or replaces
16
+ them for this app's actual priorities ("offline matters more than a11y for a field-work
17
+ app").
18
+
19
+ | Quality (ISO/IEC 25010) | Scenario | Backing |
20
+ |---|---|---|
21
+ | Maintainability | An AI session adds a feature; the lane names any layer violation as a clause, not a style nit | `[enforced: ARCH-01..05]` |
22
+ | Reliability | A source fails; the failure crosses layers as a typed `DomainError`, never a raw exception, and the screen shows a mapped error state | `[enforced: ARCH-06/07/08]` |
23
+ | Reliability (offline) | Network drops mid-session; cached Room data still renders, UI shows degraded state | `[advisory]` today — `NetworkMonitor` and Room ship as infrastructure (§4, §7) but no repository wires the cache-first fallback yet; clause candidate |
24
+ | Interaction capability (a11y) | Every interactive element is perceivable by assistive tech and automation | `[enforced: SHELL-04 + A11y gates]` |
25
+ | Security | The debug inspector HTTP server (§3) never ships in a release build | `[advisory]` today — true by source-set placement (`androidDebug`), not yet gated; clause candidate (`DEBUG-01`) |
26
+
27
+ ## 2. Constraints
28
+
29
+ - **The version set is frozen and moves as one set.** Kotlin, KSP, Compose Multiplatform, Room,
30
+ and AGP are pinned together in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml)
31
+ (currently Kotlin `2.2.20` / KSP `2.2.20-2.0.4` / Compose Multiplatform `1.10.3` / Room
32
+ `2.8.4` / AGP `8.7.3`; KSP **must** be `<kotlin>-<ksp>` or Room's KMP native compilation
33
+ breaks). Bump the set together via `npx create-cmp-cli upgrade` (proven-green sets), never
34
+ one library at a time — an isolated bump is how the native build gets `MainKt`/`Continuation`
35
+ link errors. `[advisory — no version-drift gate ships yet]`
36
+ - **Platform commitment:** Kotlin Multiplatform + Compose Multiplatform, one shared UI/logic
37
+ tree across Android and iOS (`composeApp/src/commonMain`). Android `minSdk 24` / `compileSdk
38
+ 35`; iOS deployment target `16.0` (`iosApp/Podfile`). A `jvm("desktop")` target exists too —
39
+ see §4, it is harness infrastructure, not a shipped app target.
40
+ - **The harness conventions are the baseline** — Clean Architecture, hand-written fakes, a
41
+ verify-lane definition of done — recorded in
42
+ [`docs/adr/0001-adopt-the-create-cmp-harness-conventions.md`](./adr/0001-adopt-the-create-cmp-harness-conventions.md).
43
+ Deviating from a convention gets its own ADR, not a silent drift.
44
+
45
+ ## 3. System context
4
46
 
5
47
  ```
6
- ┌─────────────────────────────────────────────────────┐
7
- presentation Screens (Compose) · ViewModels
8
- │ └─ depends on domain only │
9
- ├─────────────────────────────────────────────────────┤
10
- domain models · repository INTERFACES · │
11
- │ use cases — imports nothing app-internal │
12
- ├─────────────────────────────────────────────────────┤
13
- │ data repository implementations · │
14
- │ remote/local sources │
15
- └─────────────────────────────────────────────────────┘
16
- di/ wires implementations to interfaces (Koin)
48
+ User ──taps/reads──▶ This app (Android APK · iOS framework)
49
+
50
+ Firebase Room NetworkMonitor
51
+ (GitLive SDK: (on-device (platform connectivity,
52
+ auth/firestore/ SSOT — StateFlow<Boolean>)
53
+ functions/ AppDatabase,
54
+ storage) ItemDao)
55
+
56
+ Development-time only — never in a release build or on a user's device:
57
+ Debug inspector HTTP server QA harness
58
+ (androidDebug, loopback-only, (Maestro E2E, desktop preview
59
+ adb-forwarded) daemon) — drives the app
60
+ from the outside
17
61
  ```
18
62
 
19
- - `presentation` never imports `data`. ViewModels call **use cases**, not repositories.
20
- - `domain` is pure Kotlin — no Compose, no Koin, no platform types.
21
- - `data` implements the domain's repository interfaces; sources stay behind them.
63
+ | Integration | What | Where in the tree | Notes |
64
+ |---|---|---|---|
65
+ | Firebase | Auth / Firestore / Functions / Storage via the GitLive KMP SDK | `data/remote/FirebaseConfig.kt`, wired at `initKoin()`/`AppApplication`/`KoinHelper` | Emulator-backed in debug builds (`configureFirebaseEmulators()`); `google-services.json` ships as a placeholder — wire the real project before shipping. |
66
+ | Room | On-device SSOT — `AppDatabase`, `ItemDao` | `data/local/*.kt` | Registered in DI on every platform (`single<AppDatabase>`); the exemplar's `ItemRepositoryImpl` does not yet read/write it — see §1's offline row. |
67
+ | NetworkMonitor | Platform connectivity as `StateFlow<Boolean>` | `core/connectivity/NetworkMonitor.kt` (expect) + one `actual` per platform | Registered in DI (`single { NetworkMonitor(...) }`); not yet consumed by a repository — available, not wired. |
68
+ | Debug inspector HTTP server | Loopback-only (`ServerSocket`, never the LAN) structural/crash/DB inspection endpoint for the AI verification loop | `composeApp/src/androidDebug/kotlin/.../inspector/*.kt` | **Never compiled into `androidRelease`** — a separate no-op twin ships there (source-set placement, not a runtime flag). |
69
+ | QA harness | Maestro E2E flows + the desktop preview daemon | `qa/e2e/*.yaml`, `composeApp/src/desktopMain/.../inspector/PreviewHarness.kt` | Development/CI-time actors, never shipped to a device. |
70
+
71
+ ## 4. Platform & deployment view
72
+
73
+ **Source-set map** (`composeApp/src/`):
74
+
75
+ | Source set | Role |
76
+ |---|---|
77
+ | `commonMain` | Shared UI + logic — presentation, domain, data, di, core. The vast majority of the app lives here. |
78
+ | `commonTest` | Unit tests (kotlin-test + coroutines-test + Turbine), hand-written fakes (`testing/fakes/`). |
79
+ | `androidMain` | Android entry point (`AppApplication`), platform `actual`s. |
80
+ | `androidDebug` | Debug-only additions layered on `androidMain` — the inspector HTTP server, crash recorder, DB inspector. Never in `androidRelease`. |
81
+ | `androidRelease` | Release-only twins (currently a no-op inspector stub) that make the `androidDebug` additions compile out cleanly. |
82
+ | `iosMain` | iOS entry point (`MainViewController.kt`, `KoinHelper.kt`), platform `actual`s. |
83
+ | `desktopMain` | The JVM tier: dev-client window/hot-reload, the preview-render harness, and desktop DI — **harness infrastructure**, not a shipped app target (project ADR `0003`). |
84
+ | `desktopTest` | Conformance gates (this document's enforced clauses), Compose UI Tests, golden-tree structural baselines — the fast, device-free verification tier. |
85
+
86
+ **The expect/actual boundary.** Every shared declaration, with one `actual` per platform
87
+ that needs it. Each `actual` wraps that platform's own connectivity, persistence, or
88
+ automation API — open the file for the concrete mechanism. `AppDatabaseConstructor`'s
89
+ `actual` is generated by the Room KSP compiler plugin at build time, so no source file
90
+ appears for it:
91
+
92
+ <!-- cmp:generated expect-actual-table -->
93
+ | Declaration | commonMain (expect) | androidMain (actual) | iosMain (actual) | desktopMain (actual) |
94
+ |---|---|---|---|---|
95
+ | `AppDatabaseConstructor` | `data/local/AppDatabase.kt` | _(Room KSP-generated — no actual in source)_ | _(Room KSP-generated — no actual in source)_ | _(Room KSP-generated — no actual in source)_ |
96
+ | `Modifier.exposeTestTagsForAutomation()` | `presentation/components/TestTagAutomation.kt` | `presentation/components/TestTagAutomation.android.kt` | `presentation/components/TestTagAutomation.ios.kt` | `presentation/components/TestTagAutomation.desktop.kt` |
97
+ | `getDatabaseBuilder()` | `data/local/DatabaseBuilder.kt` | `data/local/DatabaseBuilder.android.kt` | `data/local/DatabaseBuilder.ios.kt` | `data/local/DatabaseBuilder.desktop.kt` |
98
+ | `NetworkMonitor` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.kt` | `core/connectivity/NetworkMonitor.desktop.kt` |
99
+ <!-- /cmp:generated -->
100
+
101
+ **iOS topology:** the Kotlin framework (`composeApp`) is consumed by an Xcode project generated
102
+ from `iosApp/project.yml` (XcodeGen) with dependencies via CocoaPods (`iosApp/Podfile`,
103
+ deployment target `16.0`). `MainViewController.kt` bridges into `ContentView.swift`;
104
+ `KoinHelper.kt` starts the shared Koin graph from Swift. Build by opening
105
+ `iosApp/iosApp.xcworkspace` — never the `.xcodeproj` directly, or CocoaPods dependencies won't
106
+ resolve.
107
+
108
+ **Desktop's role:** the `jvm("desktop")` target is unconditional harness infrastructure —
109
+ present regardless of feature flags — hosting `desktopTest` (this document's gates) and,
110
+ if the dev-client feature is enabled, an interactive hot-reload window. It is not a shipped
111
+ release target. See project ADR
112
+ [`0003-jvm-desktop-target-is-harness-infrastructure.md`](./adr/0003-jvm-desktop-target-is-harness-infrastructure.md).
113
+
114
+ ## 5. Building blocks — the layer model
115
+
116
+ ```
117
+ ┌──────────────────────────────────────────────────────────────────┐
118
+ │ presentation Screens (Compose) · ViewModels · the component │
119
+ │ registry (presentation/components/) │
120
+ │ └─ depends on domain only [enforced: ARCH-01] │
121
+ ├──────────────────────────────────────────────────────────────────┤
122
+ │ domain models · repository INTERFACES · use cases · │
123
+ │ AppResult/DomainError — imports nothing │
124
+ │ app-internal [enforced: ARCH-02] │
125
+ ├──────────────────────────────────────────────────────────────────┤
126
+ │ data local/ (Room: AppDatabase, ItemDao) │
127
+ │ remote/ (repository implementations, FirebaseConfig)│
128
+ │ never reaches into presentation or di │
129
+ │ [enforced: ARCH-09] │
130
+ ├──────────────────────────────────────────────────────────────────┤
131
+ │ core leaf utility code (connectivity, format) — │
132
+ │ importable by every layer above; imports domain │
133
+ │ at most, never presentation/data/di │
134
+ │ [enforced: ARCH-10] │
135
+ └──────────────────────────────────────────────────────────────────┘
136
+ di/ wires data implementations to domain interfaces (Koin) —
137
+ the one place allowed to import both.
138
+ ```
139
+
140
+ Every arrow in that box is a cited rule, not a wish:
141
+
142
+ - `presentation → domain` only; ViewModels call **use cases**, never repositories directly
143
+ `[enforced: ARCH-01]`.
144
+ - `domain` is pure Kotlin — no Compose, no Koin, no platform types
145
+ `[enforced: ARCH-02]`.
146
+ - `data` implements domain's repository interfaces and never reaches upward into
147
+ `presentation` or `di` `[enforced: ARCH-09]`.
148
+ - `core` is leaf utility code, importable by every other layer; it imports `domain` at most,
149
+ never `presentation`/`data`/`di` `[enforced: ARCH-10]`.
150
+ - The typed error boundary between `data` and everything above it
151
+ `[enforced: ARCH-06/07/08 — see §7]`.
152
+
153
+ <!-- cmp:generated layer-file-inventory -->
154
+ - `presentation/` — commonMain: `App.kt`, `components/AppBottomBar.kt`, `components/AppButton.kt`, `components/AppHeader.kt`, `components/BaseScreen.kt`, `components/ContentStateContainer.kt`, `components/ContentUiState.kt`, `components/EmptyState.kt`, `components/ErrorState.kt`, `components/ListItemCard.kt`, `components/ScreenColumn.kt`, `components/Shimmer.kt`, `components/TestTagAutomation.kt`, `home/DetailScreen.kt`, `home/HomeScreen.kt`, `home/HomeViewModel.kt`, `navigation/AppNavHost.kt`, `navigation/AppShell.kt`, `navigation/AppTab.kt`, `navigation/NavInspectionHook.kt`, `navigation/Screen.kt`, `profile/ProfileScreen.kt`, `theme/DesignToken.kt`, `theme/Shape.kt`, `theme/Theme.kt`, `theme/Tokens.kt`, `theme/Typography.kt`; androidMain: `components/TestTagAutomation.android.kt`; iosMain: `components/TestTagAutomation.ios.kt`; desktopMain: `components/TestTagAutomation.desktop.kt`
155
+ - `domain/` — commonMain: `model/DomainError.kt`, `model/Item.kt`, `repository/ItemRepository.kt`, `result/AppResult.kt`, `usecase/GetItemsUseCase.kt`
156
+ - `data/` — commonMain: `AppResultCatching.kt`, `local/AppDatabase.kt`, `local/DatabaseBuilder.kt`, `local/ItemDao.kt`, `remote/FirebaseConfig.kt`, `remote/ItemRepositoryImpl.kt`; androidMain: `local/DatabaseBuilder.android.kt`; iosMain: `local/DatabaseBuilder.ios.kt`; desktopMain: `local/DatabaseBuilder.desktop.kt`
157
+ - `core/` — commonMain: `connectivity/NetworkMonitor.kt`, `format/Format.kt`; androidMain: `connectivity/NetworkMonitor.kt`; iosMain: `connectivity/NetworkMonitor.kt`; desktopMain: `connectivity/NetworkMonitor.desktop.kt`
158
+ - `di/` — commonMain: `AppModule.kt`; androidMain: `AndroidModule.kt`; desktopMain: `DesktopModule.kt`
159
+ <!-- /cmp:generated -->
160
+
161
+ ## 6. Runtime view
162
+
163
+ **The UDF loop:** `Screen` collects `StateFlow<UiState>` from its ViewModel → user intent calls
164
+ a ViewModel function → the ViewModel invokes a use case → repository → sources → new immutable
165
+ `UiState` is emitted. No state lives in composables beyond UI-local concerns.
166
+
167
+ Three named scenarios ground that loop in what actually happens on this codebase:
168
+
169
+ 1. **Cold start.** `AppApplication.onCreate()` (Android) / `KoinHelper.initKoin()` (iOS) starts
170
+ the Koin graph — DI modules register, `NetworkMonitor` and `AppDatabase` come online — then
171
+ the Compose entry point (`MainActivity`/`MainViewController`) composes `App()`, which themes
172
+ and hosts `AppNavHost()`; the NavHost's shell destination renders `AppShell` (bottom nav) with
173
+ the first tab's screen.
174
+ 2. **Load with typed-failure handling.** A screen's `init { load() }` calls a use case, which
175
+ calls the repository. The repository's I/O runs inside `suspendRunCatching`
176
+ `[enforced: ARCH-08]`, which returns `AppResult.Success` or maps the failure to a typed
177
+ `DomainError` — never throws. The ViewModel folds the result into `ContentUiState`
178
+ (`Loading`/`Content`/`Empty`/`Error`) `[enforced: ARCH-07]` and the screen renders the
179
+ matching arm via `ContentStateContainer`. **Not yet wired:** a cache-first branch through
180
+ Room when `NetworkMonitor.isOnline` is false. Both pieces of infrastructure exist (§3, §7);
181
+ no repository reads them today — hence the offline goal's `[advisory]` status in §1.
182
+ 3. **Navigate + process death.** `AppNavHost` (single-Activity/single-`ComposeUIViewController`)
183
+ owns the back stack; each screen's `ViewModel` (scoped via `viewModelOf`/Koin) survives
184
+ configuration change but not process death — a killed-and-restored process re-runs cold
185
+ start and reloads from the repository, the same path as scenario 1.
22
186
 
23
- ## Data flow (unidirectional)
187
+ ## 7. Crosscutting policies
24
188
 
25
- `Screen` collects `StateFlow<UiState>` from its ViewModel → user intent calls a ViewModel
26
- function → the ViewModel invokes a use case → repository → sources → new immutable `UiState`
27
- is emitted. No state lives in composables beyond UI-local concerns.
189
+ ### Error handling `[enforced: ARCH-06/07/08]`
28
190
 
29
- ## The exemplar: the `home` feature
191
+ Failures cross layer boundaries as **typed results, never exceptions**:
30
192
 
31
- `presentation/home` + `domain/{model,repository,usecase}` + `data/remote` is the **reference
32
- implementation** of the pattern including its tests (`commonTest`). To add a feature, mirror
33
- it exactly:
193
+ - **`AppResult<T>`** (`domain/result/AppResult.kt`) is the boundary type: `Success(value)` or
194
+ `Failure(error: DomainError)`. One-shot repository operations return it; they never throw.
195
+ (Deliberately not `kotlin.Result` — its untyped `Throwable` would put raw exceptions right
196
+ back on the boundary.)
197
+ - **`DomainError`** (`domain/model/DomainError.kt`) is the typed failure vocabulary — KINDS
198
+ only (`Network`, `NotFound`, `Unexpected(cause)`), no message strings. Extend it with the
199
+ kinds your sources actually produce.
200
+ - **The repository implementation is the only translation point.** I/O runs inside
201
+ `suspendRunCatching` (`data/AppResultCatching.kt`), which maps infrastructure exceptions to
202
+ `DomainError` via its `mapError` classifier and **always rethrows `CancellationException`**
203
+ — swallowing cancellation breaks structured concurrency (a closed screen would render an
204
+ error instead of just stopping). `suspendRunCatching` is the data layer's *only* allowed
205
+ catch mechanism (enforced: ARCH-08); ad-hoc `try`/`catch` in `data/` fails the gate.
206
+ - **ViewModels contain no `try`/`catch`** (enforced: ARCH-07). They `when` over the
207
+ `AppResult` into a **sealed UiState** (`Loading` / `Content` / `Empty` / `Error`) —
208
+ impossible states are unrepresentable. User-facing error copy is mapped in presentation
209
+ from the `DomainError` kind (see the exemplar's `toUserMessage()`); a raw
210
+ `Throwable.message` never reaches the UI.
211
+
212
+ The sealed-UiState shape and the `toUserMessage()` placement are `[advisory]` convention,
213
+ carried by the exemplar and its tests, not gated.
214
+
215
+ ### Threading (main-safety policy) `[advisory — THREAD-01 staged, not shipped]`
216
+
217
+ **Repositories are main-safe by delegation, not by ceremony.** Every I/O path the scaffold
218
+ ships is main-safe under its own library's contract, so the template injects no dispatcher:
219
+
220
+ - **Room suspend DAO calls** (`data/local/ItemDao.kt` — all `suspend fun`s): Room executes
221
+ suspending queries on its own background executor; calling them from `Dispatchers.Main` is
222
+ safe by Room's documented contract.
223
+ - **GitLive Firebase suspend APIs** (when you wire them into `data/remote/`): suspending
224
+ wrappers over the async native SDKs — main-safe by the SDK's contract.
225
+ - **The example source** (`data/remote/ItemRepositoryImpl.kt`): only `delay()` (a suspension,
226
+ not a block) and list construction.
227
+
228
+ The rule when you add a source that does NOT carry such a guarantee (JDBC, direct file I/O,
229
+ heavy parsing, any blocking call): inject a `CoroutineDispatcher` into the repository via
230
+ Koin (default `Dispatchers.IO`) and wrap the blocking work in `withContext(dispatcher)` —
231
+ injected, not hardcoded, so tests can pass a test dispatcher. Do **not** add `withContext`
232
+ around calls that are already main-safe by contract; it's dead weight that hides where the
233
+ real guarantee lives.
234
+
235
+ ### DI `[advisory]`
236
+
237
+ One Koin module per concern: `repositoryModule` / `useCaseModule` / `viewModelModule`
238
+ (`di/AppModule.kt`, common to every platform), plus one platform module for platform-only
239
+ bindings (`AndroidModule.kt`, `DesktopModule.kt`; iOS wires its platform singletons inline in
240
+ `KoinHelper.kt`). **Constructor injection only** — see `GetItemsUseCase(repository:
241
+ ItemRepository)` and `HomeViewModel(getItems: GetItemsUseCase)`; no field/property injection,
242
+ no service-locator lookups inside domain or presentation code. Platform modules provide
243
+ `actual`-backed singletons (`NetworkMonitor`, `AppDatabase`) that common modules then depend on
244
+ via the domain-facing interface, not the concrete platform type.
245
+
246
+ ### Logging `[advisory — no shared logger ships today]`
247
+
248
+ The scaffold ships **no cross-platform logging library**. The only logging present is Koin's
249
+ `androidLogger()` (DI diagnostics, Android only) and `android.util.Log` calls confined to
250
+ the debug-only `inspector` package (§3) — both Android-specific, neither usable from
251
+ `commonMain`. If you need cross-platform application logging, add a KMP logging library
252
+ (e.g. Kermit) behind a thin interface in `core/`, inject it via Koin, and never call a
253
+ platform logger directly from `commonMain`. Nothing enforces this yet; it is a documented
254
+ gap.
255
+
256
+ ### expect/actual `[advisory]`
257
+
258
+ Shared code that needs a platform capability declares an `expect` in `commonMain` and one
259
+ `actual` per consuming platform — see §4's table (`NetworkMonitor`, `getDatabaseBuilder()`,
260
+ `AppDatabaseConstructor`, `Modifier.exposeTestTagsForAutomation()`). Convention, not a gate:
261
+ put the `expect` next to the domain-shaped contract it serves (`core/` or `data/local/`, not a
262
+ grab-bag `platform/` package), and keep the `actual`'s signature a mechanical mirror — logic
263
+ differences belong behind the shared type, not in divergent call sites.
264
+
265
+ ### Persistence `[advisory]`
266
+
267
+ Room (`data/local/AppDatabase.kt`, `ItemDao.kt`) is the on-device single source of truth,
268
+ built via the shared `buildDatabase()` (`data/local/DatabaseBuilder.kt`, using
269
+ `BundledSQLiteDriver()` for Room-on-Kotlin/Native) and registered in DI on every platform.
270
+ It is wired but **not yet consumed**: `ItemRepositoryImpl` returns in-memory sample data,
271
+ not a Room-backed cache (see §1, §6). When you wire a real persistence path, the repository
272
+ stays the only place that talks to `AppDatabase`/`ItemDao` — domain never sees Room types;
273
+ map `ItemEntity` → the domain `Item` inside `data/` — and schema changes get a `version`
274
+ bump. `fallbackToDestructiveMigration` is a scaffold-stage convenience; replace it before
275
+ shipping user data you can't afford to lose.
276
+
277
+ ### Design tokens `[enforced: ARCH-05]`
278
+
279
+ `presentation/theme/` (`Tokens.kt`, `Theme.kt`, `Typography.kt`, `Shape.kt`) is the only source
280
+ of design values — no hardcoded `Color(0x…)` literals outside it. The registry's own
281
+ components own the token call sites (declared once per component, correct everywhere it's
282
+ used), so a screen almost never touches a token directly.
283
+
284
+ ### Automation reachability `[enforced: ARCH-04, ARCH-11, SHELL-04]`
285
+
286
+ Every screen root and interactive element is testTag-addressable
287
+ (`presentation/components/TestTagAutomation.kt` exposes tags to both platforms' UI-automation
288
+ layers) — either a literal `testTag`, or `screenTag =` wiring into a registry component, whose
289
+ derived tags (`<screenTag>_screen`/`_title`/`_loading`/`_error`/`_retry`/`_empty`) count as the
290
+ same automation-reachability. Loading is never hand-rolled (`ARCH-11`) — no screen outside
291
+ `presentation/components/` references `CircularProgressIndicator`/`LinearProgressIndicator`
292
+ directly; bind the loading arm to `ContentStateContainer` instead.
293
+
294
+ ### Insets `[enforced: SHELL-03, SHELL-05]`
295
+
296
+ Insets are owned by `BaseScreen`/`AppShell` — new screens compose inside `BaseScreen` and never
297
+ re-solve edge-to-edge padding with a direct inset API call.
298
+
299
+ ## 8. Decisions & glossary
300
+
301
+ <!-- cmp:generated adr-index -->
302
+ | ADR | Title | Status |
303
+ |---|---|---|
304
+ | [0001](./adr/0001-adopt-the-create-cmp-harness-conventions.md) | Adopt the create-cmp harness conventions | accepted |
305
+ | [0002](./adr/0002-maestro-over-appium-for-e2e.md) | Maestro over Appium for E2E | accepted |
306
+ | [0003](./adr/0003-jvm-desktop-target-is-harness-infrastructure.md) | The JVM desktop target is harness infrastructure | accepted |
307
+ | [0004](./adr/0004-fakes-not-mocks-for-unit-tests.md) | Fakes, not mocks, for unit tests | accepted |
308
+ <!-- /cmp:generated -->
309
+
310
+ <!-- cmp:generated glossary -->
311
+ _Domain glossary — seeded from the `## Glossary` section of [`specs/intent.md`](../specs/intent.md) once the genesis intent interview fills it in; empty on a fresh scaffold._
312
+ <!-- /cmp:generated -->
313
+
314
+ ## The exemplar feature (`home` by default, configurable)
315
+
316
+ The **configured exemplar** — `exemplarFeature` in `qa/approvals.json`, `home` on a fresh
317
+ scaffold — is the **reference implementation** of every pattern above, including its tests
318
+ (`commonTest`). The genesis walk typically promotes your own first feature to exemplar (see
319
+ `CLAUDE.md`'s genesis section); `qa/scaffold-feature.mjs` then clones from *it*, and `home`
320
+ demotes to a regular feature. To add a feature, mirror the exemplar exactly:
34
321
 
35
322
  1. Domain: model + repository interface + use case (+ tests).
36
323
  2. Data: repository implementation (+ test through the domain contract).
37
- 3. Presentation: `<Feature>Screen` (testTag-rooted) + `<Feature>ViewModel` with
38
- `StateFlow<UiState>` (+ test using a fake from `testing/fakes/`).
324
+ 3. Presentation: `<Feature>Screen` composed from the component vocabulary
325
+ (`presentation/components/*.kt` `ScreenColumn` for the root, `AppHeader` for the
326
+ title, `ContentStateContainer` for the loading/content/empty/error dispatch,
327
+ `ListItemCard` for rows) + `<Feature>ViewModel` with a `StateFlow<ContentUiState<T>>`
328
+ (no `try`/`catch`, fold over `AppResult`, `List<E>.toContentState()` for the
329
+ empty/content split) (+ test using a fake from `testing/fakes/`).
39
330
  4. DI: register in `di/AppModule.kt`.
40
331
  5. Navigation: add the route in `presentation/navigation/`.
41
332
  6. Run `node qa/verify.mjs` — done means PASS + committed receipt.
42
333
 
43
- ## Conventions
44
-
45
- - **Theme tokens** (`presentation/theme/`) are the only source of design values — no hardcoded
46
- colors/spacing/radii in screens.
47
- - **testTags** on every screen root and interactive element (`TestTagAutomation` exposes them
48
- to E2E tooling on both platforms).
49
- - **Insets** are owned by `BaseScreen` — new screens compose inside it and never re-solve
50
- edge-to-edge padding.
51
- - Significant decisions get an ADR in [`docs/adr/`](./adr/) — see the template there.
334
+ Significant decisions get an ADR in [`docs/adr/`](./adr/) — see the template there.
@@ -24,10 +24,11 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
24
24
  - **Frameworks:** `kotlin-test` assertions · `kotlinx-coroutines-test` (`runTest`,
25
25
  `StandardTestDispatcher`) · **Turbine** for Flow/StateFlow.
26
26
  - **Fakes, never mocks.** Every repository/source interface gets a hand-written fake in
27
- `commonTest/…/testing/fakes/` — configurable (`shouldFail`, seeded data) and
28
- call-recording. Mocking frameworks are banned: they're JVM-only in KMP and hide bad seams.
27
+ `commonTest/…/testing/fakes/` — configurable (a typed `failure: DomainError?`, seeded data)
28
+ and call-recording; it returns `AppResult.Failure`, it never throws (the domain contract
29
+ doesn't). Mocking frameworks are banned: they're JVM-only in KMP and hide bad seams.
29
30
  - **Style:** Arrange-Act-Assert; behavior-named backtick tests
30
- (`` `emits error message when repository fails` ``); one behavior per test; no shared
31
+ (`` `emits Content when the repository returns items` ``); one behavior per test; no shared
31
32
  mutable state between tests.
32
33
  - **ViewModels:** install a `StandardTestDispatcher` as Main (`@BeforeTest setMain` /
33
34
  `@AfterTest resetMain`) because `viewModelScope` launches on Main; assert state with
@@ -38,8 +39,8 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
38
39
 
39
40
  | You added | You also add |
40
41
  |---|---|
41
- | a ViewModel | a `*ViewModelTest` (states: loading, success, failure, retry) |
42
- | a use case | a `*UseCaseTest` (behavior + failure propagation) |
42
+ | a ViewModel | a `*ViewModelTest` (sealed states: loading, content, empty, error, retry) |
43
+ | a use case | a `*UseCaseTest` (behavior + typed-failure passthrough) |
43
44
  | a repository impl | a test through its DOMAIN interface |
44
45
  | a screen | a testTag root (E2E reachable) |
45
46
 
@@ -0,0 +1,39 @@
1
+ # ADR-0002: Maestro over Appium for E2E
2
+
3
+ - **Status:** accepted
4
+ - **Date:** (scaffold date)
5
+
6
+ ## Context
7
+
8
+ Device E2E is the least AI-load-bearing layer in this project's testing pyramid — the AI
9
+ collaborator's real verification instrument is the structural inspector (the preview loop
10
+ described in `CLAUDE.md`'s "UI feedback loop"), not black-box UI driving. That reframes what
11
+ the E2E layer should optimize for: least brittleness and cross-platform reach, not language
12
+ uniformity with the rest of the stack (which is Kotlin end to end everywhere else).
13
+
14
+ ## Decision
15
+
16
+ This project's sole E2E layer is Maestro YAML flows. Flows live in `qa/e2e/*.yaml`; the verify
17
+ lane's `e2eSmoke` step runs `maestro test` against them. Maestro is Apache-2.0, free to run
18
+ locally and in CI on both Android and iOS simulators — only the hosted Maestro Cloud device
19
+ farm is paid, and this project brings its own emulator/simulator, so it's never needed.
20
+ Selectors reference testTags exclusively (surfaced as resource-ids on Android and
21
+ accessibility identifiers on iOS via `TestTagAutomation`), never display text, keeping flows
22
+ l10n-stable.
23
+
24
+ ## Consequences
25
+
26
+ - Auto-waits eliminate most flake; one YAML flow drives both platforms with no per-platform
27
+ driver code to maintain.
28
+ - Trade-off accepted: Maestro flows are not compile-checked. This project compensates by
29
+ keeping E2E thin (boot + a few critical journeys, see `qa/e2e/smoke.yaml`) and putting
30
+ compile-checked, interaction-level assertions in Compose UI Test instead
31
+ (`composeApp/src/desktopTest`).
32
+ - No Appium runner exists in this project — don't add one for a "just this one flow" need;
33
+ extend the Maestro flows or add a Compose UI Test instead.
34
+
35
+ ## Related
36
+
37
+ - `docs/TESTING.md` — the test pyramid this project uses, Maestro's place in it.
38
+ - `CLAUDE.md` — "UI feedback loop", the primary AI verification instrument E2E is deliberately
39
+ thin relative to.
@@ -0,0 +1,39 @@
1
+ # ADR-0003: The JVM desktop target is harness infrastructure
2
+
3
+ - **Status:** accepted
4
+ - **Date:** (scaffold date)
5
+
6
+ ## Context
7
+
8
+ This project's `jvm("desktop")` Gradle target (`composeApp/build.gradle.kts`) hosts the fast,
9
+ device-free verification tier — unit tests, the conformance gates this document's clauses are
10
+ backed by (`docs/ARCHITECTURE.md` §5/§7), Compose UI Tests, and golden-tree renders, all run
11
+ via `./gradlew :composeApp:desktopTest`. It sits alongside an optional interactive dev-client
12
+ feature (a hot-reload window, gated by feature flags at scaffold time) that happens to reuse
13
+ the same JVM target for its window and Compose Hot Reload wiring.
14
+
15
+ ## Decision
16
+
17
+ The `jvm("desktop")` target is unconditional harness infrastructure in this project, present
18
+ regardless of which optional features were scaffolded, decoupled from the interactive
19
+ dev-client experience that merely reuses it. Only the window/hot-reload/foojay pieces specific
20
+ to interactive desktop development are feature-gated; the `kspDesktop` and `desktopTest`
21
+ dependencies that back the verification tier are unconditional.
22
+
23
+ ## Consequences
24
+
25
+ - Disabling the dev-client feature never removes verification capability — only the
26
+ interactive desktop window and hot reload go away; `node qa/verify.mjs` and
27
+ `:composeApp:desktopTest` keep working exactly the same.
28
+ - The dev-client feature's footprint in the build file is limited to what it truly owns
29
+ (the window, hot-reload wiring); the harness's own test tier is never at risk of being
30
+ pruned by an unrelated feature toggle.
31
+ - Anyone editing the `jvm("desktop")` block in `composeApp/build.gradle.kts` should read the
32
+ surrounding comment first — it exists specifically to prevent this target being mistaken
33
+ for optional dev-client scaffolding and deleted along with it.
34
+
35
+ ## Related
36
+
37
+ - `composeApp/build.gradle.kts`, the `jvm("desktop")` target comment — the in-repo record of
38
+ this decision.
39
+ - `docs/ARCHITECTURE.md` §4 ("Platform & deployment view") — desktop's documented role.