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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/lib/tabs.mjs +6 -0
- package/template/CLAUDE.md +24 -6
- package/template/README.md +9 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +108 -19
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +3 -3
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +11 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +14 -5
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +33 -2
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +43 -1
- package/template/docs/ARCHITECTURE.md +55 -0
- package/template/docs/TESTING.md +7 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +44 -28
- package/template/qa/verify.mjs +63 -6
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +5 -0
package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt
CHANGED
|
@@ -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 =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt
CHANGED
|
@@ -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))
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt
CHANGED
|
@@ -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
|
|
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
|
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
package/template/docs/TESTING.md
CHANGED
|
@@ -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__
|
package/template/qa/lib/a11y.mjs
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
typeof
|
|
68
|
-
|
|
69
|
-
|
|
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 ${
|
|
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),
|
|
14
|
-
//
|
|
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.
|
|
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
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
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: "
|
|
305
|
-
label:
|
|
306
|
-
files: [
|
|
307
|
-
|
|
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");
|