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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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 both inspector dirs AND strip the `inspector` marker blocks in AppApplication.kt (import + startInspector() call) and composeApp/src/androidDebug/AndroidManifest.xml (INTERNET permission). Zero dependencies (java.net.ServerSocket + kotlinx-serialization from commonMain); no build-file markers needed."
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
+ }