create-cmp-cli 0.9.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 (29) hide show
  1. package/README.md +16 -0
  2. package/package.json +1 -1
  3. package/src/lib/tabs.mjs +6 -0
  4. package/template/CLAUDE.md +24 -6
  5. package/template/README.md +9 -0
  6. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  7. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
  8. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  9. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
  10. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  11. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
  12. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
  13. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  14. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
  15. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
  16. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  17. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
  18. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  19. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  20. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  21. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
  22. package/template/docs/ARCHITECTURE.md +55 -0
  23. package/template/docs/TESTING.md +7 -0
  24. package/template/qa/e2e/smoke.yaml +6 -0
  25. package/template/qa/lib/a11y.mjs +17 -8
  26. package/template/qa/lib/approvals.mjs +44 -28
  27. package/template/qa/verify.mjs +63 -6
  28. package/template/qa/walkthrough.mjs +499 -0
  29. package/template/specs/app-base.spec.md +5 -0
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
5
5
  import androidx.compose.ui.text.TextStyle
6
6
  import androidx.compose.ui.text.font.FontFamily
7
7
  import androidx.compose.ui.text.font.FontWeight
8
+ import androidx.compose.ui.unit.TextUnit
8
9
  import androidx.compose.ui.unit.sp
9
10
  import org.jetbrains.compose.resources.Font
10
11
  import __PACKAGE__.generated.resources.DMSans_Bold
@@ -22,15 +23,78 @@ val DmSansFontFamily: FontFamily
22
23
  Font(Res.font.DMSans_Bold, weight = FontWeight.Bold),
23
24
  )
24
25
 
26
+ /**
27
+ * One rung of the ramp, as plain data. The ramp has to be readable WITHOUT a
28
+ * composition — the preview harness publishes it into `design-system.json`, and
29
+ * the studio console's Design language page renders the type ramp from that
30
+ * catalog. Holding the numbers here (rather than only inside the [Typography]
31
+ * factory below, which needs `@Composable` for the font family) is what keeps
32
+ * the published ramp and the rendered ramp the same numbers by construction.
33
+ *
34
+ * [tracking] is nullable on purpose: `null` means "leave letter spacing
35
+ * unspecified", which is NOT the same as `0.sp` — Compose resolves those
36
+ * differently, and flattening one into the other would silently retrack the
37
+ * mid-ramp styles.
38
+ */
39
+ data class __THEME_PREFIX__TypeStyle(
40
+ val name: String,
41
+ val weight: Int,
42
+ val sizeSp: Int,
43
+ val lineHeightSp: Int,
44
+ val tracking: Float? = null,
45
+ )
46
+
47
+ /**
48
+ * A full ramp, not a minimal one. `display*` are the hero numerics (counts,
49
+ * totals, dashboard figures) — heavy and optically tightened; `headline`/`title`
50
+ * structure the screens; `label*` carries small ALL-CAPS eyebrows and metric
51
+ * units. Tight negative tracking on the big sizes is what makes a data-forward
52
+ * screen read as a considered product rather than a form — a thin ramp is the
53
+ * single cheapest tell of a scaffold.
54
+ */
55
+ val __THEME_PREFIX__TypeRamp: List<__THEME_PREFIX__TypeStyle> = listOf(
56
+ __THEME_PREFIX__TypeStyle("displayLarge", weight = 700, sizeSp = 56, lineHeightSp = 58, tracking = -1.5f),
57
+ __THEME_PREFIX__TypeStyle("displayMedium", weight = 700, sizeSp = 44, lineHeightSp = 46, tracking = -1.0f),
58
+ __THEME_PREFIX__TypeStyle("displaySmall", weight = 700, sizeSp = 32, lineHeightSp = 36, tracking = -0.5f),
59
+ __THEME_PREFIX__TypeStyle("headlineLarge", weight = 700, sizeSp = 28, lineHeightSp = 32, tracking = -0.5f),
60
+ __THEME_PREFIX__TypeStyle("headlineMedium", weight = 600, sizeSp = 22, lineHeightSp = 28, tracking = -0.3f),
61
+ __THEME_PREFIX__TypeStyle("titleLarge", weight = 600, sizeSp = 18, lineHeightSp = 24),
62
+ __THEME_PREFIX__TypeStyle("titleMedium", weight = 600, sizeSp = 16, lineHeightSp = 22),
63
+ __THEME_PREFIX__TypeStyle("bodyLarge", weight = 400, sizeSp = 16, lineHeightSp = 24),
64
+ __THEME_PREFIX__TypeStyle("bodyMedium", weight = 400, sizeSp = 14, lineHeightSp = 20),
65
+ __THEME_PREFIX__TypeStyle("labelLarge", weight = 600, sizeSp = 14, lineHeightSp = 18),
66
+ __THEME_PREFIX__TypeStyle("labelMedium", weight = 500, sizeSp = 12, lineHeightSp = 16, tracking = 0.5f),
67
+ __THEME_PREFIX__TypeStyle("labelSmall", weight = 600, sizeSp = 11, lineHeightSp = 14, tracking = 0.8f),
68
+ )
69
+
70
+ private fun ramp(name: String): __THEME_PREFIX__TypeStyle =
71
+ __THEME_PREFIX__TypeRamp.first { it.name == name }
72
+
25
73
  @Composable
26
74
  fun remember__THEME_PREFIX__Typography(): Typography {
27
75
  val dmSans = DmSansFontFamily
76
+ fun style(name: String): TextStyle {
77
+ val spec = ramp(name)
78
+ return TextStyle(
79
+ fontFamily = dmSans,
80
+ fontWeight = FontWeight(spec.weight),
81
+ fontSize = spec.sizeSp.sp,
82
+ lineHeight = spec.lineHeightSp.sp,
83
+ letterSpacing = spec.tracking?.sp ?: TextUnit.Unspecified,
84
+ )
85
+ }
28
86
  return Typography(
29
- displayLarge = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.Bold, fontSize = 32.sp),
30
- headlineMedium = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.SemiBold, fontSize = 24.sp),
31
- titleMedium = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.SemiBold, fontSize = 18.sp),
32
- bodyLarge = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.Normal, fontSize = 16.sp),
33
- bodyMedium = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.Normal, fontSize = 14.sp),
34
- labelSmall = TextStyle(fontFamily = dmSans, fontWeight = FontWeight.Medium, fontSize = 12.sp),
87
+ displayLarge = style("displayLarge"),
88
+ displayMedium = style("displayMedium"),
89
+ displaySmall = style("displaySmall"),
90
+ headlineLarge = style("headlineLarge"),
91
+ headlineMedium = style("headlineMedium"),
92
+ titleLarge = style("titleLarge"),
93
+ titleMedium = style("titleMedium"),
94
+ bodyLarge = style("bodyLarge"),
95
+ bodyMedium = style("bodyMedium"),
96
+ labelLarge = style("labelLarge"),
97
+ labelMedium = style("labelMedium"),
98
+ labelSmall = style("labelSmall"),
35
99
  )
36
100
  }
@@ -1,6 +1,5 @@
1
1
  package __PACKAGE__.inspector
2
2
 
3
- import androidx.compose.foundation.background
4
3
  import androidx.compose.foundation.layout.Arrangement
5
4
  import androidx.compose.foundation.layout.Box
6
5
  import androidx.compose.foundation.layout.BoxScope
@@ -10,9 +9,11 @@ import androidx.compose.foundation.layout.fillMaxSize
10
9
  import androidx.compose.foundation.layout.height
11
10
  import androidx.compose.foundation.layout.padding
12
11
  import androidx.compose.material.icons.Icons
12
+ import androidx.compose.material.icons.automirrored.filled.ArrowBack
13
13
  import androidx.compose.material.icons.filled.Home
14
14
  import androidx.compose.material.icons.filled.Person
15
15
  import androidx.compose.material3.Icon
16
+ import androidx.compose.material3.Surface
16
17
  import androidx.compose.material3.Text
17
18
  import androidx.compose.runtime.Composable
18
19
  import androidx.compose.ui.Alignment
@@ -22,6 +23,7 @@ import androidx.compose.ui.semantics.testTag
22
23
  import androidx.compose.ui.unit.dp
23
24
  import __PACKAGE__.presentation.components.AppBottomBar
24
25
  import __PACKAGE__.presentation.components.AppHeader
26
+ import __PACKAGE__.presentation.components.AppIconButton
25
27
  import __PACKAGE__.presentation.components.AppPrimaryButton
26
28
  import __PACKAGE__.presentation.components.AppTextButton
27
29
  import __PACKAGE__.presentation.components.BaseScreen
@@ -140,6 +142,21 @@ fun componentStories(): List<ScreenPreview> = listOf(
140
142
  modifier = Modifier.semantics { testTag = "story_text_disabled" },
141
143
  )
142
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
+ },
143
160
  // The four-state contract: all four arms of the container, stacked.
144
161
  variantsStory("component.content-state-container", "ContentStateContainer") {
145
162
  ContentStateContainer<List<String>>(
@@ -262,8 +279,22 @@ private fun variantsStory(
262
279
  * generated registry can host the PlaceholderScreen story on custom-tab
263
280
  * scaffolds (PlaceholderScreen ships only when a configured tab has no
264
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.
265
290
  */
266
291
  @Composable
267
292
  internal fun StoryHost(content: @Composable BoxScope.() -> Unit) {
268
- Box(Modifier.fillMaxSize().background(__THEME_PREFIX__Colors.Background)) { content() }
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
+ }
269
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
  }
@@ -19,7 +19,8 @@ import kotlin.math.roundToInt
19
19
  * inspector contract (schemaVersion 1, source "headless-jvm"). Every node carries pixel,
20
20
  * root-relative `bounds` and a (possibly empty) `children` array; testTag / text /
21
21
  * contentDescription / designToken are nullable; `role` / `clickable` / `disabled` are the
22
- * additive interaction fields.
22
+ * additive interaction fields, and `size` is the additive full-composed-size field (see
23
+ * [sizeJson]).
23
24
  *
24
25
  * This dumper reads THIS project's [DesignTokenKey], so the resolved design tokens the
25
26
  * component kit self-reports (Modifier.designToken) appear in the dump — which is what
@@ -54,10 +55,22 @@ object PreviewSemanticsJson {
54
55
  put("clickable", JsonPrimitive(node.config.contains(SemanticsActions.OnClick)))
55
56
  put("disabled", JsonPrimitive(node.config.contains(SemanticsProperties.Disabled)))
56
57
  put("bounds", node.boundsJson())
58
+ put("size", node.sizeJson())
57
59
  put("designToken", node.designTokenJson())
58
60
  put("children", buildJsonArray { node.children.forEach { add(nodeToJson(it)) } })
59
61
  }
60
62
 
63
+ /**
64
+ * Full composed (UNCLIPPED) size. `bounds` is the visible slice after ancestor clipping —
65
+ * a scroll container's fold truncates it — while `size` is what the node actually
66
+ * measures. Additive contract field (consumers treat it as optional); the a11y audit
67
+ * judges touch targets on it so a fold-clipped list row is never a false violation.
68
+ */
69
+ private fun SemanticsNode.sizeJson(): JsonObject = buildJsonObject {
70
+ put("width", JsonPrimitive(size.width))
71
+ put("height", JsonPrimitive(size.height))
72
+ }
73
+
61
74
  private fun SemanticsNode.boundsJson(): JsonObject {
62
75
  val rect = boundsInRoot
63
76
  return buildJsonObject {
@@ -340,6 +340,44 @@ class ArchitectureConformanceTest {
340
340
  )
341
341
  }
342
342
 
343
+ // SPEC: ARCH-12
344
+ @Test
345
+ fun `ARCH-12 sample fixtures never cross into production wiring`() {
346
+ // The UI-first pattern's hazard gate. A stateless screen may default its own state
347
+ // parameter to a `sample*` fixture — that same-file default IS the preview seam —
348
+ // and the preview registry / stories / tests (desktopMain, commonTest) render it
349
+ // freely. But the moment ANOTHER commonMain file references the fixture (a nav
350
+ // host resolving entities from sample data, a repository seeding from it), fake
351
+ // data is driving production behavior. Proven drift: the Fuelled dogfood run's
352
+ // AppNavHost read `sampleFoods.firstOrNull { it.id == foodId }` for a real route.
353
+ val sampleDecl = Regex("""\bval\s+(sample[A-Z][A-Za-z0-9]*)""")
354
+ val declarations = mutableMapOf<String, File>() // symbol -> declaring file
355
+ for (file in sources(commonMain)) {
356
+ for (m in sampleDecl.findAll(file.readText())) declarations[m.groupValues[1]] = file
357
+ }
358
+ if (declarations.isEmpty()) return
359
+ val offenders = mutableListOf<String>()
360
+ for (file in sources(commonMain)) {
361
+ val lines = nonCommentLines(file)
362
+ for ((symbol, declaring) in declarations) {
363
+ if (file == declaring) continue
364
+ if (lines.any { Regex("""\b$symbol\b""").containsMatchIn(it) }) {
365
+ offenders.add("${file.path} (references $symbol from ${declaring.name})")
366
+ }
367
+ }
368
+ }
369
+ if (offenders.isNotEmpty()) fail(
370
+ violation(
371
+ "ARCH-12", "a sample*/fixture symbol is never referenced outside its declaring file, " +
372
+ "the preview registry, or test sources — sample data is a preview seam, not " +
373
+ "production wiring.",
374
+ offenders.distinct(),
375
+ "wire the real path (repository → use case → ViewModel) and keep the sample only as " +
376
+ "the stateless screen's default parameter.",
377
+ )
378
+ )
379
+ }
380
+
343
381
  // SPEC: SHELL-05
344
382
  @Test
345
383
  fun `SHELL-05 every non-shell nav destination wraps its content in BaseScreen`() {
@@ -353,7 +391,11 @@ class ArchitectureConformanceTest {
353
391
  ?: sources(commonMain).firstOrNull { under(it, "navigation") && it.readText().contains("composable(") }
354
392
  ?: return
355
393
  val text = navHost.readText()
356
- val screenCall = Regex("""([A-Z][A-Za-z0-9]*Screen)\s*\(""")
394
+ // Both destination shapes: the plain `XScreen(...)` AND the UI-first seam's
395
+ // VM-backed `XRoute(...)` wrapper — keying on `Screen(` alone left every *Route
396
+ // destination uninspected, passing the gate vacuously (dogfood finding: the
397
+ // invariant held in Fuelled, but only by convention, not because this asserted it).
398
+ val screenCall = Regex("""([A-Z][A-Za-z0-9]*(?:Screen|Route))\s*\(""")
357
399
  // A call with only a trailing lambda has no paren — `BaseScreen { … }` — so match both.
358
400
  val baseScreenCall = Regex("""BaseScreen\s*[({]""")
359
401
  val allSources = sources(commonMain)
@@ -281,6 +281,61 @@ of design values — no hardcoded `Color(0x…)` literals outside it. The regist
281
281
  components own the token call sites (declared once per component, correct everywhere it's
282
282
  used), so a screen almost never touches a token directly.
283
283
 
284
+ ### UI-first construction `[advisory, with one enforced hazard gate: ARCH-12]`
285
+
286
+ Screens are built **UI-first**: a stateless composable over a plain state value, rendered in the
287
+ preview gallery before any ViewModel or data layer exists. The pattern has four parts, and the
288
+ fourth is a gate:
289
+
290
+ 1. **Stateless screen** — `XScreen(state, onEvent)`; it owns no data and calls no VM.
291
+ 2. **Preview seam** — the screen's state parameter defaults to a `sampleX` fixture declared in the
292
+ same file, so the preview registry renders it with zero DI. This is what lets a screen exist —
293
+ and be judged, styled, and approved — before its vertical slice is built.
294
+ 3. **Wiring obligation** — the sample default is scaffolding, not a destination. The screen ends up
295
+ driven by a VM-backed `XRoute` wrapper (the actual nav destination; `SHELL-05` inspects both
296
+ suffixes), and the slice follows the exemplar layer-for-layer.
297
+ 4. **The hazard → enforced.** Sample data must never leak into production wiring: a `sample*`
298
+ symbol is referenced only in its declaring file (the seam), the preview registry/stories, and
299
+ tests. `ARCH-12` fails the lane on any other `commonMain` reference — a nav host resolving
300
+ entities from sample data is the proven failure mode this exists to stop.
301
+
302
+ This pattern is why the genesis walk can lock the design system and distill the component registry
303
+ *after* real screens exist — the visual artifacts are extracted from the product, not guessed ahead
304
+ of it.
305
+
306
+ ### Component vocabulary `[governed — registry membership is judgment]`
307
+
308
+ `presentation/components/` is the design-system registry — the shared vocabulary screens compose
309
+ from. It is **distilled from real screens, not authored before them**: build the screens, then
310
+ promote the primitives that pass the bar. What earns a place is **judgment, not a reuse count** —
311
+ govern a composable when it (1) encodes a *design-system decision* (how the product presents
312
+ something — a progress ring, a metric tile, a chip) rather than *how one screen arranges its data*;
313
+ (2) is a *stable, obvious* shape, not a speculative one; (3) would cause visible inconsistency if
314
+ each screen reinvented it; or (4) centralizes a cross-cutting concern worth enforcing once (a11y,
315
+ tokens — as `AppButton` does the 48 dp target). Keep it **feature-local** when it is a one-screen
316
+ composition, carries feature/domain logic, or genuinely differs from its neighbors. Reuse count is
317
+ a *signal* (≥2 real uses is strong evidence), never the rule — a single-use stable primitive is
318
+ still a component; a five-times-duplicated set of *different* shapes is not.
319
+
320
+ Two guardrails are first-class:
321
+
322
+ - **Do not force reuse.** Cramming genuinely-different composables into one over-parameterized
323
+ component (leading slot + trailing slot + toggle + chevron + value…) is a *worse* antipattern
324
+ than duplication — god-components with leaky parameter lists and hidden coupling. "Both are rows"
325
+ is not a reason to unify; only near-identical shape **and** behavior is. **When in doubt, keep
326
+ them separate.**
327
+ - **A domain-named component is a smell.** A composable named for a domain concept (`MacroTag`) is
328
+ a feature decision in design-system clothes — generalize it to a real primitive (`Tag` = label +
329
+ value + colour) or keep it local. Never admit domain vocabulary to the registry. Brand marks
330
+ (`presentation/brand/`) are their own category, governed separately.
331
+
332
+ Any conformance gate here flags **only** true near-identical duplication or re-invention of an
333
+ existing registry component — never "you have two rows, unify them" (that would push toward the
334
+ antipattern above), and it never *forces* a promotion. Division of labor: the **agent makes the
335
+ rubric calls** — classifies each screen composable, promotes or keeps local, with reasoning — and
336
+ the **human governs at the Components approval**, which is exactly what that gate exists for.
337
+ Nothing the agent promotes is law until the approval signs it.
338
+
284
339
  ### Automation reachability `[enforced: ARCH-04, ARCH-11, SHELL-04]`
285
340
 
286
341
  Every screen root and interactive element is testTag-addressable
@@ -54,6 +54,13 @@ Maestro flows (`qa/e2e/*.yaml`) cover boot + bottom-nav — install the free CLI
54
54
  (`curl -fsSL "https://get.maestro.mobile.dev" | bash`). Selectors go by **testTag** (`id:` —
55
55
  surfaced as resource-ids via `TestTagAutomation`), never by display text. One flow per
56
56
  journey, spec-clause cited; keep the E2E tip small — behavior belongs in unit tests.
57
+
58
+ **Settle rule:** an assertion that follows an interaction triggering an async state change
59
+ (typing a search query, a toggle that persists, a load) must be an `extendedWaitUntil`, not
60
+ a bare `assertVisible` — the ViewModel round-trip passes through a brief Loading arm that a
61
+ lane-loaded emulator stretches, and asserting into that transition is a false red (a real
62
+ one: a search assert that passed standalone failed in-lane behind a 33s type gap). Bare
63
+ asserts are for static post-navigation elements only.
57
64
  <!-- <<< cmp:feature e2e -->
58
65
 
59
66
  ## The verify lane
@@ -6,6 +6,12 @@
6
6
  # AppShell.kt's navItemTag (lowercase, non-[a-z0-9] runs collapsed to "_", trimmed);
7
7
  # keep these ids in sync with it if the configured tabs change.
8
8
  #
9
+ # SETTLE RULE for the behaviour steps you add: any assertion that follows an interaction
10
+ # triggering an async state change (search input, a toggle that persists, a load) must use
11
+ # extendedWaitUntil — the ViewModel round-trip passes through a brief Loading arm, and a
12
+ # lane-loaded emulator stretches that window past a bare assert's patience (a real false
13
+ # red from the field). Bare assertVisible is for static post-navigation elements only.
14
+ #
9
15
  # Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
10
16
  # The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
11
17
  appId: __PACKAGE__
@@ -5,7 +5,13 @@
5
5
  // touch-target-too-small — a clickable node whose width or height is below the
6
6
  // minimum touch target (default 48px; the harness dumps
7
7
  // at density 1 so px == dp there — pass a different
8
- // minTouchTargetPx for device-density trees).
8
+ // minTouchTargetPx for device-density trees). Judged on the
9
+ // FULL composed size (the tree's additive `size` field) when
10
+ // present: `bounds` is the visible slice after ancestor
11
+ // clipping, so a list row bisected by a scroll fold reports
12
+ // e.g. 371x36 while measuring 371x88 — a scroll-position
13
+ // artifact, never an a11y defect. Trees without `size`
14
+ // (older dumps) are judged on bounds, as before.
9
15
  // missing-label — a clickable node with no text, no contentDescription,
10
16
  // and no descendant text: nothing for a screen reader.
11
17
  // Rules (warnings):
@@ -61,17 +67,20 @@ export function auditA11y(tree, opts = {}) {
61
67
 
62
68
  let violated = false;
63
69
 
70
+ // Judge the FULL composed size when the dump carries it (`size` is unclipped; `bounds`
71
+ // is the visible slice after ancestor clipping — see the rule doc above). max() keeps
72
+ // the check honest either way: a genuinely small target is small in both.
64
73
  const b = node.bounds;
65
- if (
66
- b &&
67
- typeof b.width === "number" &&
68
- typeof b.height === "number" &&
69
- (b.width < minTouchTargetPx || b.height < minTouchTargetPx)
70
- ) {
74
+ const s = node.size;
75
+ const dim = (bv, sv) =>
76
+ Math.max(typeof bv === "number" ? bv : -1, typeof sv === "number" ? sv : -1);
77
+ const width = dim(b && b.width, s && s.width);
78
+ const height = dim(b && b.height, s && s.height);
79
+ if (width >= 0 && height >= 0 && (width < minTouchTargetPx || height < minTouchTargetPx)) {
71
80
  violations.push({
72
81
  ...entryBase,
73
82
  rule: "touch-target-too-small",
74
- detail: `clickable node is ${b.width}x${b.height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
83
+ detail: `clickable node is ${width}x${height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
75
84
  });
76
85
  violated = true;
77
86
  }
@@ -10,10 +10,13 @@
10
10
  //
11
11
  // Three concerns, kept separable:
12
12
  // 1. The REGISTRY (`listGovernedArtifacts`) — artifact id -> resolved file list, in
13
- // GENESIS-FLOW-DESIGN.md §1 order: intent(0), design-system(1), architecture(2),
14
- // components(3), exemplar-feature(4), exemplar-spec(5), then one
13
+ // GENESIS-FLOW-DESIGN.md §1 order: intent(0), architecture(1), exemplar-spec(2),
14
+ // exemplar-feature(3), design-system(4), components(5), then one
15
15
  // `feature-spec:<name>` (6+) per non-base, non-exemplar spec file present in
16
- // specs/ right now. The exemplar (feature 4/5) is CONFIGURABLE — see
16
+ // specs/ right now. (Spec-first: the exemplar's clauses are confirmed before the
17
+ // slice is built. UI-first: design system + components are distilled from the
18
+ // real screens, so they lock after the exemplar.) The exemplar (2/3) is
19
+ // CONFIGURABLE — see
17
20
  // `getExemplarFeature`/`resolveExemplarNames` below — defaulting to `home` so
18
21
  // every ledger written before this config key existed keeps meaning what it
19
22
  // meant. The registry is recomputed on every call — it reflects the tree as it
@@ -235,9 +238,17 @@ function listComponentFiles(root) {
235
238
 
236
239
  /**
237
240
  * The governed-artifact registry, resolved against the project at `root` right
238
- * now (GENESIS-FLOW-DESIGN.md §1 order: intent(0), design-system(1),
239
- * architecture(2), components(3), exemplar-feature(4), exemplar-spec(5), then one
240
- * feature-spec:<name> (6+) per non-base, non-CONFIGURED-exemplar spec present).
241
+ * now. GENESIS-FLOW-DESIGN.md §1 definition order two ordering principles,
242
+ * one per artifact kind (the dogfooding-run correction):
243
+ * BEHAVIORAL artifacts are SPEC-FIRST the exemplar's clauses are proposed
244
+ * and human-confirmed BEFORE the slice is built (exemplar-spec precedes
245
+ * exemplar-feature, matching add-feature's discipline).
246
+ * VISUAL artifacts are UI-FIRST — the design system and component vocabulary
247
+ * are distilled FROM the real screens, so they lock AFTER the exemplar
248
+ * exists (a provisional palette carries the build until then).
249
+ * Order: intent(0), architecture(1), exemplar-spec(2), exemplar-feature(3),
250
+ * design-system(4), components(5), then one feature-spec:<name> (6+) per
251
+ * non-base, non-CONFIGURED-exemplar spec present.
241
252
  *
242
253
  * `complete: false` marks an artifact whose kotlin-rooted files could NOT be
243
254
  * resolved (unresolvable package — raw template / pre-stamp tree). Such an
@@ -258,16 +269,6 @@ export function listGovernedArtifacts(root) {
258
269
  complete: true,
259
270
  });
260
271
 
261
- artifacts.push({
262
- id: "design-system",
263
- label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
264
- files: [
265
- kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"),
266
- kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt"),
267
- ].filter(Boolean),
268
- complete: packageResolved,
269
- });
270
-
271
272
  artifacts.push({
272
273
  id: "architecture",
273
274
  label: `Architecture + structure (${ARCHITECTURE_SPEC_REL} + ${ARCH_DOC_REL_PATH}, generated sections stripped)`,
@@ -279,20 +280,22 @@ export function listGovernedArtifacts(root) {
279
280
  complete: true,
280
281
  });
281
282
 
282
- artifacts.push({
283
- id: "components",
284
- label: "Components (presentation/components/*.kt)",
285
- files: listComponentFiles(root),
286
- complete: packageResolved,
287
- });
288
-
289
283
  const { f: exemplarF, F: exemplarF_Pascal, E: exemplarE } = resolveExemplarNames(root);
290
284
  const exemplarSpecRel = `specs/${exemplarF}.spec.md`;
291
285
  const exemplarKotlinFiles = exemplarKotlinFileSet(exemplarF_Pascal, exemplarF, exemplarE);
292
286
 
287
+ // Spec-first: the exemplar's behavior clauses are confirmed BEFORE the slice
288
+ // is built — the definition order is the discipline, not just a display order.
289
+ artifacts.push({
290
+ id: "exemplar-spec",
291
+ label: `Exemplar spec (${exemplarSpecRel})`,
292
+ files: [exemplarSpecRel],
293
+ complete: true,
294
+ });
295
+
293
296
  artifacts.push({
294
297
  id: "exemplar-feature",
295
- label: `Exemplar feature (${exemplarF} — the 11-file set the stamper clones)`,
298
+ label: `Exemplar feature (${exemplarF} — the file set the stamper clones)`,
296
299
  files: [
297
300
  ...exemplarKotlinFiles.map((f) => kotlinFile(root, f.sourceSet, f.rel)).filter(Boolean),
298
301
  exemplarSpecRel,
@@ -300,11 +303,24 @@ export function listGovernedArtifacts(root) {
300
303
  complete: packageResolved,
301
304
  });
302
305
 
306
+ // UI-first: the design system LOCKS on the real exemplar (candidates render on
307
+ // real screens, never stubs), and the component vocabulary is DISTILLED from
308
+ // those screens — both follow the exemplar in the definition order.
303
309
  artifacts.push({
304
- id: "exemplar-spec",
305
- label: `Exemplar spec (${exemplarSpecRel})`,
306
- files: [exemplarSpecRel],
307
- complete: true,
310
+ id: "design-system",
311
+ label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
312
+ files: [
313
+ kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"),
314
+ kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt"),
315
+ ].filter(Boolean),
316
+ complete: packageResolved,
317
+ });
318
+
319
+ artifacts.push({
320
+ id: "components",
321
+ label: "Components (presentation/components/*.kt)",
322
+ files: listComponentFiles(root),
323
+ complete: packageResolved,
308
324
  });
309
325
 
310
326
  const specsDir = path.join(root, "specs");