create-cmp-cli 0.5.0 → 0.6.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 +25 -9
- package/llms.txt +16 -1
- package/package.json +8 -3
- package/src/lib/tabs.mjs +113 -0
- package/template/AGENTS.md +8 -0
- package/template/CLAUDE.md +32 -0
- package/template/README.md +5 -0
- package/template/composeApp/build.gradle.kts +53 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +219 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +265 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +61 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +83 -0
- package/template/manifest.json +4 -2
- package/template/qa/lib/a11y.mjs +104 -0
- package/template/qa/lib/render.mjs +254 -0
- package/template/qa/lib/tree.mjs +108 -0
- package/template/qa/preview-gallery.mjs +107 -0
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.CompositionLocalProvider
|
|
5
|
+
import androidx.compose.runtime.remember
|
|
6
|
+
import androidx.compose.ui.ImageComposeScene
|
|
7
|
+
import androidx.compose.ui.graphics.toArgb
|
|
8
|
+
import androidx.compose.ui.test.ExperimentalTestApi
|
|
9
|
+
import androidx.compose.ui.test.onRoot
|
|
10
|
+
import androidx.compose.ui.test.runDesktopComposeUiTest
|
|
11
|
+
import androidx.compose.ui.unit.Density
|
|
12
|
+
import androidx.lifecycle.Lifecycle
|
|
13
|
+
import androidx.lifecycle.LifecycleOwner
|
|
14
|
+
import androidx.lifecycle.LifecycleRegistry
|
|
15
|
+
import androidx.lifecycle.ViewModelStore
|
|
16
|
+
import androidx.lifecycle.ViewModelStoreOwner
|
|
17
|
+
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
18
|
+
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
|
|
19
|
+
import __PACKAGE__.core.connectivity.NetworkMonitor
|
|
20
|
+
// >>> cmp:feature room
|
|
21
|
+
import __PACKAGE__.data.local.AppDatabase
|
|
22
|
+
import __PACKAGE__.data.local.buildDatabase
|
|
23
|
+
// <<< cmp:feature room
|
|
24
|
+
import __PACKAGE__.di.appModules
|
|
25
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Colors
|
|
26
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Theme
|
|
27
|
+
import __PACKAGE__.presentation.theme.__THEME_PREFIX__Tokens
|
|
28
|
+
import kotlinx.serialization.json.Json
|
|
29
|
+
import kotlinx.serialization.json.JsonElement
|
|
30
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
31
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
32
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
33
|
+
import org.jetbrains.skia.EncodedImageFormat
|
|
34
|
+
import org.koin.core.context.startKoin
|
|
35
|
+
import org.koin.dsl.module
|
|
36
|
+
import java.io.File
|
|
37
|
+
import kotlin.system.exitProcess
|
|
38
|
+
|
|
39
|
+
// Phone-shaped viewport, density 1 — px == dp in the dumped tree (matches the dev-client
|
|
40
|
+
// window and keeps the inspector's a11y/touch-target math exact).
|
|
41
|
+
internal const val WIDTH = 411
|
|
42
|
+
internal const val HEIGHT = 891
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Headless preview harness — the project-wired tier-0 loop of the create-cmp inspector.
|
|
46
|
+
*
|
|
47
|
+
* Renders REAL screens from [previewRegistry] with no device, emulator, or window:
|
|
48
|
+
* for each screen it writes the inspector-contract semantics tree (`tree.json`, via
|
|
49
|
+
* `runDesktopComposeUiTest`) and a pixel preview (`screen.png`, via [ImageComposeScene])
|
|
50
|
+
* from the same composition sources at the same viewport, plus the declared design-system
|
|
51
|
+
* catalog (`design-system.json`) and a `manifest.json` for gallery tooling
|
|
52
|
+
* (qa/preview-gallery.mjs turns the output into a single self-contained index.html).
|
|
53
|
+
*
|
|
54
|
+
* Real DI, real theme, real data: Koin starts with the same modules as the app, and
|
|
55
|
+
* screens resolve their ViewModels through `koinViewModel()` exactly as in production.
|
|
56
|
+
*
|
|
57
|
+
* Invoked by the `:composeApp:renderScreens` Gradle task; parameters arrive as SYSTEM
|
|
58
|
+
* PROPERTIES (never `--args`, which Gradle's CLI parsing mangles):
|
|
59
|
+
* -Pscreen=<id|all> which registry entry to render (default all)
|
|
60
|
+
* -PpreviewOut=<dir> output root (default build/previews)
|
|
61
|
+
* -PpngScale=<n> PNG density multiplier for sharpness (default 2; tree stays density 1)
|
|
62
|
+
*/
|
|
63
|
+
fun main() {
|
|
64
|
+
val screenFilter = System.getProperty("screen")?.takeIf { it.isNotBlank() } ?: "all"
|
|
65
|
+
val outRoot = File(System.getProperty("out")?.takeIf { it.isNotBlank() } ?: "build/previews")
|
|
66
|
+
val pngScale = System.getProperty("pngScale")?.toFloatOrNull()?.takeIf { it > 0f } ?: 2f
|
|
67
|
+
|
|
68
|
+
initPreviewKoin()
|
|
69
|
+
|
|
70
|
+
val all = previewRegistry()
|
|
71
|
+
val selected = if (screenFilter == "all") all else all.filter { it.id == screenFilter }
|
|
72
|
+
if (selected.isEmpty()) {
|
|
73
|
+
System.err.println(
|
|
74
|
+
"Unknown screen '$screenFilter'. Available: ${all.joinToString(", ") { it.id }} (or 'all').",
|
|
75
|
+
)
|
|
76
|
+
exitProcess(2)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
outRoot.mkdirs()
|
|
80
|
+
File(outRoot, "design-system.json").writeText(designSystemCatalog())
|
|
81
|
+
|
|
82
|
+
for (entry in selected) {
|
|
83
|
+
val dir = File(outRoot, entry.id).apply { mkdirs() }
|
|
84
|
+
renderTree(entry, File(dir, "tree.json"))
|
|
85
|
+
renderPng(entry, File(dir, "screen.png"), pngScale)
|
|
86
|
+
System.err.println("rendered ${entry.id} -> ${dir.absolutePath}")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
File(outRoot, "manifest.json").writeText(manifestJson(selected, pngScale))
|
|
90
|
+
System.err.println("previews -> ${outRoot.absolutePath}")
|
|
91
|
+
// AWT/EDT and Koin threads are non-daemon; exit explicitly once outputs are on disk.
|
|
92
|
+
exitProcess(0)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Preview-harness DI — intentionally independent of the dev-client feature (which owns
|
|
97
|
+
* DesktopModule): the same platform bindings, started once for the render run.
|
|
98
|
+
*/
|
|
99
|
+
internal fun initPreviewKoin() {
|
|
100
|
+
startKoin {
|
|
101
|
+
modules(
|
|
102
|
+
module {
|
|
103
|
+
// >>> cmp:feature room
|
|
104
|
+
single<AppDatabase> { buildDatabase() }
|
|
105
|
+
// <<< cmp:feature room
|
|
106
|
+
single { NetworkMonitor(null) }
|
|
107
|
+
},
|
|
108
|
+
*appModules.toTypedArray(),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Provides what a bare offscreen composition lacks so production screens compose
|
|
115
|
+
* unmodified: a RESUMED [LifecycleOwner] (for `collectAsStateWithLifecycle`) and a fresh
|
|
116
|
+
* [ViewModelStoreOwner] per composition (so `koinViewModel()` resolves — and each render
|
|
117
|
+
* gets fresh ViewModels), then the app theme.
|
|
118
|
+
*/
|
|
119
|
+
@Composable
|
|
120
|
+
internal fun PreviewRoot(content: @Composable () -> Unit) {
|
|
121
|
+
val owner = remember { PreviewOwner() }
|
|
122
|
+
CompositionLocalProvider(
|
|
123
|
+
LocalLifecycleOwner provides owner,
|
|
124
|
+
LocalViewModelStoreOwner provides owner,
|
|
125
|
+
) {
|
|
126
|
+
__THEME_PREFIX__Theme(content)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
internal class PreviewOwner : LifecycleOwner, ViewModelStoreOwner {
|
|
131
|
+
private val registry = LifecycleRegistry.createUnsafe(this).apply {
|
|
132
|
+
currentState = Lifecycle.State.RESUMED
|
|
133
|
+
}
|
|
134
|
+
override val lifecycle: Lifecycle get() = registry
|
|
135
|
+
override val viewModelStore = ViewModelStore()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Semantics tree at the phone viewport, density 1. ADAPTIVE settle: async data arrives in
|
|
140
|
+
* real time, so keep sampling until two consecutive dumps are identical (bounded) — static
|
|
141
|
+
* screens finish in ~300ms instead of paying the full window.
|
|
142
|
+
*/
|
|
143
|
+
@OptIn(ExperimentalTestApi::class)
|
|
144
|
+
internal fun renderTree(entry: ScreenPreview, outFile: File) {
|
|
145
|
+
var json = ""
|
|
146
|
+
runDesktopComposeUiTest(width = WIDTH, height = HEIGHT) {
|
|
147
|
+
setContent { PreviewRoot { entry.content() } }
|
|
148
|
+
waitForIdle()
|
|
149
|
+
var prev: String? = null
|
|
150
|
+
var stable = 0
|
|
151
|
+
var iterations = 0
|
|
152
|
+
while (iterations < 8 && stable < 2) {
|
|
153
|
+
Thread.sleep(150)
|
|
154
|
+
waitForIdle()
|
|
155
|
+
val dump = PreviewSemanticsJson.dumpTree(onRoot(useUnmergedTree = true).fetchSemanticsNode())
|
|
156
|
+
if (dump == prev) stable++ else { stable = 0; prev = dump }
|
|
157
|
+
iterations++
|
|
158
|
+
}
|
|
159
|
+
json = prev ?: PreviewSemanticsJson.dumpTree(onRoot(useUnmergedTree = true).fetchSemanticsNode())
|
|
160
|
+
}
|
|
161
|
+
outFile.writeText(json)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Pixel twin of the tree: same content, same dp viewport, density [scale] for sharpness.
|
|
166
|
+
* Frames are re-rendered while invalidations arrive so async data reaches the pixels too.
|
|
167
|
+
*/
|
|
168
|
+
internal fun renderPng(entry: ScreenPreview, outFile: File, scale: Float) {
|
|
169
|
+
val scene = ImageComposeScene(
|
|
170
|
+
width = (WIDTH * scale).toInt(),
|
|
171
|
+
height = (HEIGHT * scale).toInt(),
|
|
172
|
+
density = Density(scale),
|
|
173
|
+
) {
|
|
174
|
+
PreviewRoot { entry.content() }
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
var elapsedNanos = 0L
|
|
178
|
+
var image = scene.render(elapsedNanos)
|
|
179
|
+
var quiet = 0
|
|
180
|
+
var iterations = 0
|
|
181
|
+
while (iterations < 12 && quiet < 2) {
|
|
182
|
+
Thread.sleep(100)
|
|
183
|
+
elapsedNanos += 100_000_000L
|
|
184
|
+
if (scene.hasInvalidations()) {
|
|
185
|
+
image = scene.render(elapsedNanos)
|
|
186
|
+
quiet = 0
|
|
187
|
+
} else {
|
|
188
|
+
quiet++
|
|
189
|
+
}
|
|
190
|
+
iterations++
|
|
191
|
+
}
|
|
192
|
+
val data = image.encodeToData(EncodedImageFormat.PNG)
|
|
193
|
+
?: error("Skia failed to encode ${entry.id} as PNG")
|
|
194
|
+
outFile.writeBytes(data.bytes)
|
|
195
|
+
} finally {
|
|
196
|
+
scene.close()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The declared design-system catalog — generated FROM the theme objects, so it can't drift. */
|
|
201
|
+
internal fun designSystemCatalog(): String {
|
|
202
|
+
val pretty = Json { prettyPrint = true }
|
|
203
|
+
fun hex(color: androidx.compose.ui.graphics.Color): JsonElement =
|
|
204
|
+
JsonPrimitive("#%06X".format(color.toArgb() and 0xFFFFFF))
|
|
205
|
+
fun dp(value: androidx.compose.ui.unit.Dp): JsonElement =
|
|
206
|
+
JsonPrimitive("${value.value.toInt()}dp")
|
|
207
|
+
|
|
208
|
+
val doc = buildJsonObject {
|
|
209
|
+
put("colors", buildJsonObject {
|
|
210
|
+
put("Primary", hex(__THEME_PREFIX__Colors.Primary))
|
|
211
|
+
put("OnPrimary", hex(__THEME_PREFIX__Colors.OnPrimary))
|
|
212
|
+
put("Accent", hex(__THEME_PREFIX__Colors.Accent))
|
|
213
|
+
put("OnAccent", hex(__THEME_PREFIX__Colors.OnAccent))
|
|
214
|
+
put("Secondary", hex(__THEME_PREFIX__Colors.Secondary))
|
|
215
|
+
put("Error", hex(__THEME_PREFIX__Colors.Error))
|
|
216
|
+
put("Success", hex(__THEME_PREFIX__Colors.Success))
|
|
217
|
+
put("Warning", hex(__THEME_PREFIX__Colors.Warning))
|
|
218
|
+
put("Info", hex(__THEME_PREFIX__Colors.Info))
|
|
219
|
+
put("Background", hex(__THEME_PREFIX__Colors.Background))
|
|
220
|
+
put("Surface", hex(__THEME_PREFIX__Colors.Surface))
|
|
221
|
+
put("SurfaceVariant", hex(__THEME_PREFIX__Colors.SurfaceVariant))
|
|
222
|
+
put("OnSurface", hex(__THEME_PREFIX__Colors.OnSurface))
|
|
223
|
+
put("OnSurfaceVariant", hex(__THEME_PREFIX__Colors.OnSurfaceVariant))
|
|
224
|
+
put("Outline", hex(__THEME_PREFIX__Colors.Outline))
|
|
225
|
+
put("OutlineVariant", hex(__THEME_PREFIX__Colors.OutlineVariant))
|
|
226
|
+
put("Divider", hex(__THEME_PREFIX__Colors.Divider))
|
|
227
|
+
})
|
|
228
|
+
put("dimens", buildJsonObject {
|
|
229
|
+
put("ElevationCard", dp(__THEME_PREFIX__Tokens.ElevationCard))
|
|
230
|
+
put("ElevationModal", dp(__THEME_PREFIX__Tokens.ElevationModal))
|
|
231
|
+
put("PaddingPage", dp(__THEME_PREFIX__Tokens.PaddingPage))
|
|
232
|
+
put("PaddingCard", dp(__THEME_PREFIX__Tokens.PaddingCard))
|
|
233
|
+
put("GapCard", dp(__THEME_PREFIX__Tokens.GapCard))
|
|
234
|
+
put("BottomNavHeight", dp(__THEME_PREFIX__Tokens.BottomNavHeight))
|
|
235
|
+
put("RadiusCard", dp(__THEME_PREFIX__Tokens.RadiusCard))
|
|
236
|
+
put("RadiusPill", dp(__THEME_PREFIX__Tokens.RadiusPill))
|
|
237
|
+
put("RadiusModal", dp(__THEME_PREFIX__Tokens.RadiusModal))
|
|
238
|
+
put("RadiusInput", dp(__THEME_PREFIX__Tokens.RadiusInput))
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
return pretty.encodeToString(JsonElement.serializer(), doc)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
internal fun manifestJson(entries: List<ScreenPreview>, pngScale: Float): String {
|
|
245
|
+
val pretty = Json { prettyPrint = true }
|
|
246
|
+
val doc = buildJsonObject {
|
|
247
|
+
put("viewport", buildJsonObject {
|
|
248
|
+
put("width", JsonPrimitive(WIDTH))
|
|
249
|
+
put("height", JsonPrimitive(HEIGHT))
|
|
250
|
+
put("treeDensity", JsonPrimitive(1))
|
|
251
|
+
put("pngScale", JsonPrimitive(pngScale))
|
|
252
|
+
})
|
|
253
|
+
put("screens", buildJsonArray {
|
|
254
|
+
entries.forEach { entry ->
|
|
255
|
+
add(buildJsonObject {
|
|
256
|
+
put("id", JsonPrimitive(entry.id))
|
|
257
|
+
put("title", JsonPrimitive(entry.title))
|
|
258
|
+
put("tree", JsonPrimitive("${entry.id}/tree.json"))
|
|
259
|
+
put("png", JsonPrimitive("${entry.id}/screen.png"))
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
return pretty.encodeToString(JsonElement.serializer(), doc)
|
|
265
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import androidx.compose.foundation.layout.Box
|
|
4
|
+
import androidx.compose.foundation.layout.fillMaxSize
|
|
5
|
+
import androidx.compose.runtime.Composable
|
|
6
|
+
import androidx.compose.ui.Modifier
|
|
7
|
+
import __PACKAGE__.presentation.components.BaseScreen
|
|
8
|
+
import __PACKAGE__.presentation.home.DetailScreen
|
|
9
|
+
import __PACKAGE__.presentation.home.HomeScreen
|
|
10
|
+
import __PACKAGE__.presentation.navigation.AppShell
|
|
11
|
+
import __PACKAGE__.presentation.navigation.appTabs
|
|
12
|
+
import __PACKAGE__.presentation.profile.ProfileScreen
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One previewable screen: a stable [id] (the `-Pscreen=` selector and output directory
|
|
16
|
+
* name), a human [title] for the gallery, and the composable [content] exactly as the
|
|
17
|
+
* app hosts it.
|
|
18
|
+
*
|
|
19
|
+
* The `@Preview` analog for the create-cmp inspector: the registry makes "render screen
|
|
20
|
+
* X" a closed, enumerable operation. The scaffolder regenerates the tab entries from the
|
|
21
|
+
* configured `tabs`; when you add a screen by hand, add it here — the renderScreens
|
|
22
|
+
* harness, the gallery, and golden baselines pick it up by id.
|
|
23
|
+
*
|
|
24
|
+
* State variants (the Storybook "story" analog): a screen in a specific UI state is just
|
|
25
|
+
* another entry with a derived id — e.g. `ScreenPreview("home@empty", "Home — empty")`
|
|
26
|
+
* hosting the screen with that state forced (a state-first overload of the screen, or
|
|
27
|
+
* preview-only fakes behind its usual parameters). Every entry renders the same way
|
|
28
|
+
* (gallery card, `-Pscreen=` selector, golden baseline), so loading/empty/error states
|
|
29
|
+
* sit side by side with the default seeded state.
|
|
30
|
+
*/
|
|
31
|
+
data class ScreenPreview(
|
|
32
|
+
val id: String,
|
|
33
|
+
val title: String,
|
|
34
|
+
val content: @Composable () -> Unit,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/** Every registered screen, in gallery order. Ids must be unique and filesystem-safe. */
|
|
38
|
+
fun previewRegistry(): List<ScreenPreview> = listOf(
|
|
39
|
+
ScreenPreview("shell", "App shell — bottom nav (first tab selected)") {
|
|
40
|
+
AppShell(
|
|
41
|
+
tabs = appTabs(
|
|
42
|
+
home = { HomeScreen(onItemClick = {}) },
|
|
43
|
+
profile = { ProfileScreen() },
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
},
|
|
47
|
+
ScreenPreview("home", "Home tab") { TabHost { HomeScreen(onItemClick = {}) } },
|
|
48
|
+
ScreenPreview("profile", "Profile tab") { TabHost { ProfileScreen() } },
|
|
49
|
+
ScreenPreview("detail", "Detail (nav destination)") { DetailScreen(itemId = "1", onBack = {}) },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hosts a single tab's content the way [AppShell] does — inside [BaseScreen] — minus the
|
|
54
|
+
* bottom bar, so a tab previews with the same insets/background it gets in the shell.
|
|
55
|
+
*/
|
|
56
|
+
@Composable
|
|
57
|
+
private fun TabHost(content: @Composable () -> Unit) {
|
|
58
|
+
BaseScreen {
|
|
59
|
+
Box(Modifier.fillMaxSize()) { content() }
|
|
60
|
+
}
|
|
61
|
+
}
|
package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
package __PACKAGE__.inspector
|
|
2
|
+
|
|
3
|
+
import androidx.compose.ui.semantics.SemanticsActions
|
|
4
|
+
import androidx.compose.ui.semantics.SemanticsNode
|
|
5
|
+
import androidx.compose.ui.semantics.SemanticsProperties
|
|
6
|
+
import androidx.compose.ui.semantics.getOrNull
|
|
7
|
+
import __PACKAGE__.presentation.theme.DesignTokenKey
|
|
8
|
+
import kotlinx.serialization.json.Json
|
|
9
|
+
import kotlinx.serialization.json.JsonElement
|
|
10
|
+
import kotlinx.serialization.json.JsonNull
|
|
11
|
+
import kotlinx.serialization.json.JsonObject
|
|
12
|
+
import kotlinx.serialization.json.JsonPrimitive
|
|
13
|
+
import kotlinx.serialization.json.buildJsonArray
|
|
14
|
+
import kotlinx.serialization.json.buildJsonObject
|
|
15
|
+
import kotlin.math.roundToInt
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walks a Compose [SemanticsNode] tree and serialises it to JSON matching the create-cmp
|
|
19
|
+
* inspector contract (schemaVersion 1, source "headless-jvm"). Every node carries pixel,
|
|
20
|
+
* root-relative `bounds` and a (possibly empty) `children` array; testTag / text /
|
|
21
|
+
* contentDescription / designToken are nullable; `role` / `clickable` / `disabled` are the
|
|
22
|
+
* additive interaction fields.
|
|
23
|
+
*
|
|
24
|
+
* This dumper reads THIS project's [DesignTokenKey], so the resolved design tokens the
|
|
25
|
+
* component kit self-reports (Modifier.designToken) appear in the dump — which is what
|
|
26
|
+
* makes the tree design-system-aware, not just geometry.
|
|
27
|
+
*/
|
|
28
|
+
object PreviewSemanticsJson {
|
|
29
|
+
|
|
30
|
+
private val prettyJson = Json { prettyPrint = true }
|
|
31
|
+
|
|
32
|
+
fun dumpTree(root: SemanticsNode): String {
|
|
33
|
+
val doc = buildJsonObject {
|
|
34
|
+
put("schemaVersion", JsonPrimitive(1))
|
|
35
|
+
put("source", JsonPrimitive("headless-jvm"))
|
|
36
|
+
put("root", nodeToJson(root))
|
|
37
|
+
}
|
|
38
|
+
return prettyJson.encodeToString(JsonElement.serializer(), doc)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private fun nodeToJson(node: SemanticsNode): JsonObject = buildJsonObject {
|
|
42
|
+
put("testTag", node.config.getOrNull(SemanticsProperties.TestTag).toJson())
|
|
43
|
+
put(
|
|
44
|
+
"text",
|
|
45
|
+
node.config.getOrNull(SemanticsProperties.Text)
|
|
46
|
+
?.joinToString(" ") { it.text }?.takeIf { it.isNotEmpty() }.toJson(),
|
|
47
|
+
)
|
|
48
|
+
put(
|
|
49
|
+
"contentDescription",
|
|
50
|
+
node.config.getOrNull(SemanticsProperties.ContentDescription)
|
|
51
|
+
?.joinToString(" ")?.takeIf { it.isNotEmpty() }.toJson(),
|
|
52
|
+
)
|
|
53
|
+
put("role", node.config.getOrNull(SemanticsProperties.Role)?.toString().toJson())
|
|
54
|
+
put("clickable", JsonPrimitive(node.config.contains(SemanticsActions.OnClick)))
|
|
55
|
+
put("disabled", JsonPrimitive(node.config.contains(SemanticsProperties.Disabled)))
|
|
56
|
+
put("bounds", node.boundsJson())
|
|
57
|
+
put("designToken", node.designTokenJson())
|
|
58
|
+
put("children", buildJsonArray { node.children.forEach { add(nodeToJson(it)) } })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private fun SemanticsNode.boundsJson(): JsonObject {
|
|
62
|
+
val rect = boundsInRoot
|
|
63
|
+
return buildJsonObject {
|
|
64
|
+
put("x", JsonPrimitive(rect.left.roundToInt()))
|
|
65
|
+
put("y", JsonPrimitive(rect.top.roundToInt()))
|
|
66
|
+
put("width", JsonPrimitive(rect.width.roundToInt()))
|
|
67
|
+
put("height", JsonPrimitive(rect.height.roundToInt()))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private fun SemanticsNode.designTokenJson(): JsonElement {
|
|
72
|
+
val info = config.getOrNull(DesignTokenKey) ?: return JsonNull
|
|
73
|
+
return buildJsonObject {
|
|
74
|
+
put("tokens", buildJsonArray { info.tokens.forEach { add(JsonPrimitive(it)) } })
|
|
75
|
+
put("resolved", buildJsonObject {
|
|
76
|
+
info.resolved.forEach { (k, v) -> put(k, JsonPrimitive(v)) }
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun String?.toJson(): JsonElement =
|
|
82
|
+
if (this == null) JsonNull else JsonPrimitive(this)
|
|
83
|
+
}
|
package/template/manifest.json
CHANGED
|
@@ -94,9 +94,11 @@
|
|
|
94
94
|
"enabledByDefault": true,
|
|
95
95
|
"paths": [
|
|
96
96
|
"composeApp/src/androidDebug/kotlin/com/example/app/inspector",
|
|
97
|
-
"composeApp/src/androidRelease/kotlin/com/example/app/inspector"
|
|
97
|
+
"composeApp/src/androidRelease/kotlin/com/example/app/inspector",
|
|
98
|
+
"composeApp/src/desktopMain/kotlin/com/example/app/inspector",
|
|
99
|
+
"qa/preview-gallery.mjs"
|
|
98
100
|
],
|
|
99
|
-
"notes": "Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. When off: delete
|
|
101
|
+
"notes": "Two loops in one feature. (1) Live on-device inspector: debug-only loopback HTTP server on 127.0.0.1:9500 (GET /inspect/health|tree|design-system) serving the semantics tree + design-token catalog to the cmp-inspector MCP via `adb forward tcp:9500 tcp:9500`. The androidRelease dir holds only the no-op startInspector() twin — release builds contain no inspector code structurally. (2) Headless preview harness (tier 0): the desktopMain inspector dir (PreviewRegistry/PreviewHarness/PreviewSemanticsJson) + the :composeApp:renderScreens task render every registered screen to PNG + contract tree JSON with no device; qa/preview-gallery.mjs builds a self-contained index.html from the output (vendored pure-logic render libs live in qa/lib and stay when the feature is off — they are inert without the harness). The harness starts its OWN Koin (independent of the dev-client feature's DesktopModule) and its PreviewRegistry tab entries are regenerated from the configured `tabs` by the scaffolder (pipeline step b.3). When off: delete all three inspector dirs + qa/preview-gallery.mjs, strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call), composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission), and composeApp/build.gradle.kts (desktopMain compose.uiTest dep + the renderScreens task). On-device server: zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain)."
|
|
100
102
|
},
|
|
101
103
|
"dev-client": {
|
|
102
104
|
"enabledByDefault": true,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// a11y.mjs — accessibility audit over the tree contract.
|
|
2
|
+
// Pure logic only — no fs, no MCP imports; unit-testable.
|
|
3
|
+
//
|
|
4
|
+
// Rules (violations):
|
|
5
|
+
// touch-target-too-small — a clickable node whose width or height is below the
|
|
6
|
+
// minimum touch target (default 48px; the harness dumps
|
|
7
|
+
// at density 1 so px == dp there — pass a different
|
|
8
|
+
// minTouchTargetPx for device-density trees).
|
|
9
|
+
// missing-label — a clickable node with no text, no contentDescription,
|
|
10
|
+
// and no descendant text: nothing for a screen reader.
|
|
11
|
+
// Rules (warnings):
|
|
12
|
+
// empty-content-description — contentDescription === "" (redundant/empty; either
|
|
13
|
+
// label it or drop the attribute).
|
|
14
|
+
//
|
|
15
|
+
// Trees produced before the role/clickable/disabled contract extension are handled
|
|
16
|
+
// gracefully: nodes without `clickable` are simply skipped, never crashed on.
|
|
17
|
+
|
|
18
|
+
import { walk } from "./tree.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Audit a tree for accessibility faults.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} tree a full tree ({root}) or bare node.
|
|
24
|
+
* @param {{minTouchTargetPx?: number}} [opts]
|
|
25
|
+
* @returns {{
|
|
26
|
+
* violations: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
|
|
27
|
+
* warnings: Array<{path:string, testTag:string|null, rule:string, detail:string, bounds:object|null}>,
|
|
28
|
+
* warningCount: number,
|
|
29
|
+
* passCount: number
|
|
30
|
+
* }} passCount = clickable nodes that passed every check.
|
|
31
|
+
*/
|
|
32
|
+
export function auditA11y(tree, opts = {}) {
|
|
33
|
+
const minTouchTargetPx =
|
|
34
|
+
typeof opts.minTouchTargetPx === "number" && opts.minTouchTargetPx > 0
|
|
35
|
+
? opts.minTouchTargetPx
|
|
36
|
+
: 48;
|
|
37
|
+
|
|
38
|
+
const violations = [];
|
|
39
|
+
const warnings = [];
|
|
40
|
+
let passCount = 0;
|
|
41
|
+
|
|
42
|
+
for (const { node, path } of walk(tree)) {
|
|
43
|
+
const entryBase = {
|
|
44
|
+
path,
|
|
45
|
+
testTag: node.testTag ?? null,
|
|
46
|
+
bounds: node.bounds ?? null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Warn on redundant/empty contentDescription regardless of clickability.
|
|
50
|
+
if (node.contentDescription === "") {
|
|
51
|
+
warnings.push({
|
|
52
|
+
...entryBase,
|
|
53
|
+
rule: "empty-content-description",
|
|
54
|
+
detail: 'contentDescription is an empty string ("") — either label the node or drop the attribute',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Interactive checks only apply to nodes that self-report clickable:true.
|
|
59
|
+
// Old trees without the optional field are skipped gracefully.
|
|
60
|
+
if (node.clickable !== true) continue;
|
|
61
|
+
|
|
62
|
+
let violated = false;
|
|
63
|
+
|
|
64
|
+
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
|
+
) {
|
|
71
|
+
violations.push({
|
|
72
|
+
...entryBase,
|
|
73
|
+
rule: "touch-target-too-small",
|
|
74
|
+
detail: `clickable node is ${b.width}x${b.height}px; minimum touch target is ${minTouchTargetPx}x${minTouchTargetPx}px`,
|
|
75
|
+
});
|
|
76
|
+
violated = true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hasOwnLabel =
|
|
80
|
+
(node.text != null && node.text !== "") ||
|
|
81
|
+
(node.contentDescription != null && node.contentDescription !== "");
|
|
82
|
+
if (!hasOwnLabel && !hasDescendantText(node)) {
|
|
83
|
+
violations.push({
|
|
84
|
+
...entryBase,
|
|
85
|
+
rule: "missing-label",
|
|
86
|
+
detail: "clickable node has no text, no contentDescription, and no descendant text — invisible to screen readers",
|
|
87
|
+
});
|
|
88
|
+
violated = true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!violated) passCount++;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { violations, warnings, warningCount: warnings.length, passCount };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasDescendantText(node) {
|
|
98
|
+
for (const child of node.children || []) {
|
|
99
|
+
if (child.text != null && child.text !== "") return true;
|
|
100
|
+
if (child.contentDescription != null && child.contentDescription !== "") return true;
|
|
101
|
+
if (hasDescendantText(child)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|