diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..b9e3c1e
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,39 @@
+name: API Docs
+
+on:
+ push:
+ branches: [ main ]
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ publish-dokka:
+ name: Publish Dokka HTML
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Make gradlew executable
+ run: chmod +x ./gradlew
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 17
+ cache: gradle
+ - name: Generate Dokka HTML
+ run: ./gradlew dokkaGenerate
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: build/dokka/html
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4288726
--- /dev/null
+++ b/README.md
@@ -0,0 +1,147 @@
+# Compose A11y Scanner
+
+Runtime accessibility scanner that overlays issues directly on your Compose UI
+
+
+
+## Architecture
+
+```mermaid
+flowchart LR
+ SemanticsTree["SemanticsTree"] --> Extractor[":scanner-ui
A11yNodeExtractor"]
+ Extractor --> ColorExtractor["ColorExtractor"]
+ ColorExtractor --> Nodes["A11yNode list"]
+ Extractor --> Nodes
+ Nodes --> Core[":scanner-core
A11yScanEngine"]
+ Rules[":scanner-rules
Built-in A11yRule implementations"] --> Core
+ Core --> Result["ScanResult / ScannerState"]
+ Result --> Overlay[":scanner-ui
Overlay, summary, report sheet"]
+```
+
+`:scanner-core` owns the scan engine, models, result state, and `A11yRule` contract. `:scanner-rules` contains the built-in rules. `:scanner-ui` reads the Compose semantics tree, samples colors through `ColorExtractor`, runs scans, and draws issue overlays on top of the host UI.
+
+## Installation
+
+```kotlin
+repositories { maven("https://jitpack.io") }
+dependencies { debugImplementation("io.github.mohdaquib:scanner-ui:") }
+```
+
+Use a debug-only dependency because the scanner is intended for development builds. `:scanner-ui` brings `:scanner-core` and `:scanner-rules` transitively.
+
+## Integration
+
+### A11yScannerScaffold
+
+Wrap the screen you want to inspect with `A11yScannerScaffold`. Provide an `A11yScannerController` that can extract nodes from your current Compose semantics tree.
+
+```kotlin
+@Composable
+fun DebugScannerScreen(
+ scannerController: A11yScannerController,
+ content: @Composable () -> Unit,
+) {
+ val config = remember {
+ ScannerConfig(
+ enabledRules = ScannerRules.allRuleIds().toSet(),
+ autoScan = false,
+ )
+ }
+
+ A11yScannerScaffold(
+ scannerController = scannerController,
+ config = config,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ content()
+ }
+}
+```
+
+The scaffold renders your content first, then draws issue highlight boxes, the scan summary bar, and the issue detail sheet above it.
+
+### Shake To Scan
+
+Call `scanOnShake()` from a composable that is active while the scanned screen is visible.
+
+```kotlin
+scanOnShake(
+ enabled = true,
+ onScanRequested = {
+ scannerController.startScan()
+ },
+)
+```
+
+### Manual Trigger
+
+Wire a debug menu item, toolbar button, or test-only action directly to the controller.
+
+```kotlin
+IconButton(onClick = { scannerController.startScan() }) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Scan accessibility",
+ )
+}
+```
+
+If you use the top-level activity overlay API, call `ComposeA11yScanner.triggerScan()` instead.
+
+```kotlin
+ComposeA11yScanner.triggerScan()
+```
+
+## Built-In Rules
+
+See [RULES.md](RULES.md) for complete behavior, fixes, WCAG references, and examples.
+
+| Rule ID | Name | Severity | Details |
+| --- | --- | --- | --- |
+| `touch-target-size` | Touch Target Size | Error | [RULES.md](RULES.md#touch-target-size---touch-target-size) |
+| `missing-content-description` | Missing Content Description | Error | [RULES.md](RULES.md#missing-content-description---missing-content-description) |
+| `duplicate-content-description` | Duplicate Content Description | Warning | [RULES.md](RULES.md#duplicate-content-description---duplicate-content-description) |
+| `focus-order` | Focus Order | Error | [RULES.md](RULES.md#focus-order---focus-order) |
+| `text-scaling` | Text Scaling | Warning | [RULES.md](RULES.md#text-scaling---text-scaling) |
+| `image-text-overlay` | Image With Text Overlay | Warning | [RULES.md](RULES.md#image-text-overlay---image-with-text-overlay) |
+| `clickable-role` | Clickable Role | Error | [RULES.md](RULES.md#clickable-role---clickable-role) |
+
+## Custom Rules
+
+Create a rule by implementing `A11yRule`. Use a stable `ruleId`, assign a severity, and return an `A11yIssue` only when the node fails your check.
+
+```kotlin
+class MissingTestTagRule : A11yRule {
+ override val ruleId = "missing-test-tag"
+ override val ruleName = "Missing Test Tag"
+ override val severity = A11ySeverity.Warning
+ override val wcagReference: String? = null
+
+ override fun evaluate(node: A11yNode): A11yIssue? {
+ if (!node.isTouchTarget || node.isMergedDescendant) return null
+ if (node.composableName.contains("TestTag", ignoreCase = true)) return null
+
+ return A11yIssue(
+ issueId = "${ruleId}_${node.nodeId}",
+ severity = severity,
+ ruleId = ruleId,
+ ruleName = ruleName,
+ affectedNode = node,
+ message = "Interactive node does not expose a stable test tag.",
+ howToFix = "Add Modifier.testTag() to make this control easier to identify in tests.",
+ wcagReference = wcagReference,
+ )
+ }
+}
+```
+
+Register custom rules on the controller:
+
+```kotlin
+val scannerController = A11yScannerController(
+ nodeProvider = { extractNodesFromCurrentSemanticsTree() },
+ screenDensity = density,
+).withRules(MissingTestTagRule())
+```
+
+Custom rule IDs are automatically enabled by `A11yScannerController.withRules(...)` before each scan.
diff --git a/RULES.md b/RULES.md
new file mode 100644
index 0000000..251bba9
--- /dev/null
+++ b/RULES.md
@@ -0,0 +1,219 @@
+# Built-In Accessibility Rules
+
+Compose A11y Scanner ships the built-in rules registered by `ScannerRules.allRuleIds()`.
+
+Implementation note: the current codebase exposes 7 built-in rules, not 8. This document covers every implemented built-in rule in `scanner-rules`.
+
+## Summary
+
+| Rule ID | Name | Severity | What It Checks | How To Fix | WCAG Reference |
+| --- | --- | --- | --- | --- | --- |
+| `touch-target-size` | Touch Target Size | Error | Clickable/touch target nodes whose measured width or height is smaller than the configured minimum, 48dp by default. | Use `Modifier.minimumInteractiveComponentSize()` or add padding so the interactive area is at least the configured minimum in both dimensions. | WCAG 2.5.5 Target Size (Level AA) |
+| `missing-content-description` | Missing Content Description | Error | Interactive nodes and image-like nodes that do not expose a non-empty content description. | Add a meaningful `contentDescription` through semantics, or pass one directly to image composables that support it. | WCAG 1.1.1 Non-text Content (Level A) |
+| `duplicate-content-description` | Duplicate Content Description | Warning | Non-merged nodes at the same semantics depth that reuse the same non-empty content description. | Give each control or item a label that identifies its specific action, state, or content. | WCAG 2.4.6 Headings and Labels (Level AA) |
+| `focus-order` | Focus Order | Error | Focusable nodes whose semantics traversal jumps upward compared with the previous focusable node's visual position. | Reorder composables so focus follows the visual reading order, or set explicit traversal order with semantics. | WCAG 2.4.3 Focus Order (Level A) |
+| `text-scaling` | Text Scaling | Warning | Text nodes that may overflow or clip inside their parent when simulated at a larger font scale. | Avoid fixed-height containers for text; use flexible height, wrapping, or scrolling so scaled text can reflow. | WCAG 1.4.4 Resize Text (Level AA) |
+| `image-text-overlay` | Image With Text Overlay | Warning | Text nodes that significantly overlap image nodes, creating a contrast risk across dynamic images. | Add a scrim or solid text background, or otherwise guarantee sufficient contrast for every image state. | WCAG 1.4.3 Contrast Minimum (Level AA) |
+| `clickable-role` | Clickable Role | Error | Clickable/touch target nodes that do not expose a semantic role, and clickable image roles without a content description. | Add the appropriate role, such as `Role.Button`, `Role.Checkbox`, or `Role.Image`; provide labels for clickable images. | WCAG 4.1.2 Name, Role, Value (Level A) |
+
+## `touch-target-size` - Touch Target Size
+
+**Severity:** Error
+
+**What it checks:** This rule evaluates clickable/touch target nodes that are not merged descendants. It reports a node when either its measured width or height is smaller than `ScannerConfig.minTouchTargetDp`, which defaults to 48dp.
+
+**How to fix:** Ensure the interactive area reaches the minimum size in both dimensions. Prefer Material's `Modifier.minimumInteractiveComponentSize()` for Material controls, or add padding around custom controls.
+
+**WCAG reference:** WCAG 2.5.5 Target Size (Level AA)
+
+**Code example:**
+
+```kotlin
+IconButton(
+ onClick = onClose,
+ modifier = Modifier.minimumInteractiveComponentSize(),
+) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Close",
+ )
+}
+```
+
+## `missing-content-description` - Missing Content Description
+
+**Severity:** Error
+
+**What it checks:** This rule reports interactive nodes and image-like composables, such as `Image` or `AsyncImage`, when they do not expose a non-empty content description. Merged descendants are skipped because their parent is responsible for the final announcement.
+
+**How to fix:** Provide a meaningful label that describes the action or content. Decorative images should generally be excluded from semantics instead of receiving noisy labels.
+
+**WCAG reference:** WCAG 1.1.1 Non-text Content (Level A)
+
+**Code example:**
+
+```kotlin
+Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = "Search",
+ modifier = Modifier.clickable(onClick = onSearch),
+)
+```
+
+For a custom composable:
+
+```kotlin
+Box(
+ modifier = Modifier.semantics {
+ contentDescription = "Open account settings"
+ },
+)
+```
+
+## `duplicate-content-description` - Duplicate Content Description
+
+**Severity:** Warning
+
+**What it checks:** This rule groups non-merged nodes by semantics depth and content description. It reports nodes when more than one node at the same depth has the same non-empty content description.
+
+**How to fix:** Make labels specific enough for a screen reader user to distinguish each item or action. Include item names, destinations, or state where needed.
+
+**WCAG reference:** WCAG 2.4.6 Headings and Labels (Level AA)
+
+**Code example:**
+
+```kotlin
+Row {
+ IconButton(onClick = onEditProfile) {
+ Icon(Icons.Default.Edit, contentDescription = "Edit profile")
+ }
+
+ IconButton(onClick = onEditAddress) {
+ Icon(Icons.Default.Edit, contentDescription = "Edit address")
+ }
+}
+```
+
+## `focus-order` - Focus Order
+
+**Severity:** Error
+
+**What it checks:** This rule evaluates focusable nodes in semantics order and reports a node when focus moves upward by more than the configured threshold, 8dp by default. This catches cases where source order does not match the visible reading order.
+
+**How to fix:** Prefer arranging composables in the same order users should navigate them. If the visual layout intentionally differs from source order, use semantics traversal ordering deliberately.
+
+**WCAG reference:** WCAG 2.4.3 Focus Order (Level A)
+
+**Code example:**
+
+```kotlin
+Column {
+ TextField(
+ value = email,
+ onValueChange = onEmailChange,
+ label = { Text("Email") },
+ )
+
+ TextField(
+ value = password,
+ onValueChange = onPasswordChange,
+ label = { Text("Password") },
+ )
+
+ Button(onClick = onSubmit) {
+ Text("Sign in")
+ }
+}
+```
+
+When source order cannot match visual order:
+
+```kotlin
+Modifier.semantics {
+ traversalIndex = 1f
+}
+```
+
+## `text-scaling` - Text Scaling
+
+**Severity:** Warning
+
+**What it checks:** This rule looks for text nodes that may clip when their height is simulated at a larger font scale, 1.3x by default. It compares the scaled text bounds with the bounds of the nearest parent touch target.
+
+**How to fix:** Avoid fixed heights around text. Use flexible containers, `wrapContentHeight()`, enough vertical padding, or scrolling so text can grow with the user's font-size preference.
+
+**WCAG reference:** WCAG 1.4.4 Resize Text (Level AA)
+
+**Code example:**
+
+```kotlin
+Button(
+ onClick = onContinue,
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+) {
+ Text(
+ text = "Continue",
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+}
+```
+
+## `image-text-overlay` - Image With Text Overlay
+
+**Severity:** Warning
+
+**What it checks:** This rule finds text nodes that overlap image nodes by more than the configured overlap threshold, 50% of the text area by default. It does not calculate exact contrast from every image state; it flags the pattern as a contrast risk.
+
+**How to fix:** Place a scrim, gradient, or solid background behind the text, or move the text outside the image. Verify that normal text has at least 4.5:1 contrast and large text has at least 3:1 contrast.
+
+**WCAG reference:** WCAG 1.4.3 Contrast Minimum (Level AA)
+
+**Code example:**
+
+```kotlin
+Box {
+ Image(
+ painter = heroPainter,
+ contentDescription = "Mountain trail",
+ modifier = Modifier.fillMaxWidth(),
+ contentScale = ContentScale.Crop,
+ )
+
+ Text(
+ text = "Weekend hikes",
+ color = Color.White,
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .background(Color.Black.copy(alpha = 0.56f))
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ )
+}
+```
+
+## `clickable-role` - Clickable Role
+
+**Severity:** Error
+
+**What it checks:** This rule reports clickable/touch target nodes that are not merged descendants and do not expose a semantic role. It also reports clickable image roles when the content description is empty.
+
+**How to fix:** Use Material components when possible because they usually provide roles automatically. For custom click targets, set the appropriate role through semantics or use clickable APIs that expose the role.
+
+**WCAG reference:** WCAG 4.1.2 Name, Role, Value (Level A)
+
+**Code example:**
+
+```kotlin
+Box(
+ modifier = Modifier
+ .semantics {
+ role = Role.Button
+ contentDescription = "Retry loading"
+ }
+ .clickable(onClick = onRetry)
+ .minimumInteractiveComponentSize(),
+) {
+ Text("Retry")
+}
+```
diff --git a/build.gradle.kts b/build.gradle.kts
index 36d69e1..4e0ba1b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,3 +1,8 @@
+import com.android.build.gradle.LibraryExtension
+import org.gradle.api.publish.PublishingExtension
+import org.gradle.api.publish.maven.MavenPublication
+import org.gradle.jvm.tasks.Jar
+
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
@@ -5,10 +10,65 @@ plugins {
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.detekt)
+ alias(libs.plugins.dokka)
+ alias(libs.plugins.dokka.javadoc) apply false
+}
+
+private val publishedLibraryModules = setOf(
+ "scanner-core",
+ "scanner-rules",
+ "scanner-ui",
+)
+
+subprojects {
+ if (name !in publishedLibraryModules) return@subprojects
+
+ group = "io.github.mohdaquib"
+ version = findProperty("VERSION_NAME") as? String
+ ?: System.getenv("VERSION_NAME")
+ ?: System.getenv("JITPACK_TAG")
+ ?: "1.0.0"
+
+ apply(plugin = "maven-publish")
+ apply(plugin = "org.jetbrains.dokka")
+ apply(plugin = "org.jetbrains.dokka-javadoc")
+
+ plugins.withId("com.android.library") {
+ extensions.configure("android") {
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
+ }
+
+ val dokkaJavadocJar = tasks.register("dokkaJavadocJar") {
+ dependsOn("dokkaGeneratePublicationJavadoc")
+ archiveClassifier.set("javadoc")
+ from(layout.buildDirectory.dir("dokka/javadoc"))
+ }
+
+ afterEvaluate {
+ extensions.configure("publishing") {
+ publications {
+ create("release") {
+ from(components["release"])
+ groupId = project.group.toString()
+ artifactId = project.name
+ version = project.version.toString()
+ artifact(dokkaJavadocJar)
+ }
+ }
+ }
+ }
+ }
}
dependencies {
detektPlugins(libs.detekt.formatting)
+ dokka(project(":scanner-core"))
+ dokka(project(":scanner-rules"))
+ dokka(project(":scanner-ui"))
}
detekt {
diff --git a/docs/overlay-demo.gif b/docs/overlay-demo.gif
new file mode 100644
index 0000000..9c91eb8
Binary files /dev/null and b/docs/overlay-demo.gif differ
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c6a3f1d..9e31a14 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ mockk = "1.13.12"
turbine = "1.2.0"
detekt = "1.23.7"
paparazzi = "2.0.0-alpha05"
+dokka = "2.2.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -51,3 +52,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
+dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
+dokka-javadoc = { id = "org.jetbrains.dokka-javadoc", version.ref = "dokka" }
diff --git a/scanner-core/consumer-rules.pro b/scanner-core/consumer-rules.pro
index e69de29..7963249 100644
--- a/scanner-core/consumer-rules.pro
+++ b/scanner-core/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class com.composea11yscanner.core.** { *; }
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
index 023af10..25501cb 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/A11yScanEngine.kt
@@ -26,7 +26,7 @@ import java.util.UUID
* emissions on their own dispatcher.
*
* @param rules All candidate rules. Only those whose [A11yRule.ruleId] appears in
- * [config.enabledRules] will be evaluated — defensive against callers that pass the
+ * `config.enabledRules` will be evaluated — defensive against callers that pass the
* full rule set regardless of config.
*/
class A11yScanEngine(
@@ -35,6 +35,12 @@ class A11yScanEngine(
) {
private val enabledRules: List = rules.filter { it.ruleId in config.enabledRules }
+ /**
+ * Runs enabled rules over [nodes] and emits progress followed by a terminal state.
+ *
+ * @param nodes Nodes extracted from the UI semantics tree.
+ * @return Flow of [ScannerState] values for progress, success, or failure.
+ */
fun scan(nodes: List): Flow = flow {
// Fast path: nothing to evaluate.
if (enabledRules.isEmpty() || nodes.isEmpty()) {
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/ScannerCore.kt b/scanner-core/src/main/java/com/composea11yscanner/core/ScannerCore.kt
index 6f5a845..a05cfcb 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/ScannerCore.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/ScannerCore.kt
@@ -1,5 +1,7 @@
package com.composea11yscanner.core
+/** Core artifact metadata. */
object ScannerCore {
+ /** Library version exposed by the core artifact. */
const val VERSION = "0.1.0"
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt
index bff27c1..dfbcd32 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yIssue.kt
@@ -1,5 +1,17 @@
package com.composea11yscanner.core.model
+/**
+ * Accessibility violation produced by an [com.composea11yscanner.core.rule.A11yRule].
+ *
+ * @property issueId Stable issue identifier, usually derived from the rule id and affected node id.
+ * @property severity User-facing priority used for sorting and visual treatment.
+ * @property ruleId Stable identifier of the rule that produced this issue.
+ * @property ruleName Human-readable rule name.
+ * @property affectedNode Node that caused the rule to fail.
+ * @property message Short explanation of the problem.
+ * @property howToFix Suggested remediation text for developers.
+ * @property wcagReference Optional WCAG criterion related to the issue.
+ */
data class A11yIssue(
val issueId: String,
val severity: A11ySeverity,
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
index ca54044..89a8d23 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt
@@ -1,5 +1,21 @@
package com.composea11yscanner.core.model
+/**
+ * Normalized representation of one UI semantics node scanned by the accessibility rules.
+ *
+ * @property nodeId Stable id for grouping issues that belong to the same semantics node.
+ * @property composableName Best-effort composable or role name used in reports.
+ * @property bounds Pixel bounds relative to the scanned root.
+ * @property contentDescription Accessible label exposed by the node, if any.
+ * @property isTouchTarget True when the node exposes a click action.
+ * @property touchTargetSize Size used by touch target rules, expressed in dp.
+ * @property textColor Foreground text color when it can be extracted.
+ * @property backgroundColors Candidate background colors sampled behind the node.
+ * @property isFocusable True when the node can participate in focus traversal.
+ * @property isMergedDescendant True when the node is inside a parent that merges semantics.
+ * @property depth Depth in the semantics tree.
+ * @property role Accessibility role mapped from the platform semantics role, if any.
+ */
data class A11yNode(
val nodeId: String,
val composableName: String,
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
index 45c6221..aab66d0 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt
@@ -2,15 +2,30 @@ package com.composea11yscanner.core.model
/**
* Mirrors [androidx.compose.ui.semantics.Role] without a Compose dependency.
- * Conversion in :scanner-ui: A11yRole.from(semanticsNode.config[SemanticsProperties.Role])
+ * Conversion happens in :scanner-ui from Compose semantics roles.
*/
enum class A11yRole {
+ /** Activates an action. */
Button,
+
+ /** Two-state checkable control. */
Checkbox,
+
+ /** List-style selection control. */
DropdownList,
+
+ /** Image or image-like content. */
Image,
+
+ /** Mutually exclusive selectable option. */
RadioButton,
+
+ /** Two-state switch control. */
Switch,
+
+ /** Tab item in a tab set. */
Tab,
+
+ /** Editable text field. */
TextField,
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt
index 84ace53..933265b 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11ySeverity.kt
@@ -1,8 +1,11 @@
package com.composea11yscanner.core.model
+/** Priority level for an accessibility issue. */
sealed interface A11ySeverity : Comparable {
+ /** Numeric order used for sorting, where lower values are more severe. */
val sortOrder: Int
+ /** Sorts severities by [sortOrder]. */
override fun compareTo(other: A11ySeverity): Int = sortOrder.compareTo(other.sortOrder)
/** Must fix — blocks accessibility. */
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt
index 8ced408..ce62f36 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/Color.kt
@@ -1,15 +1,19 @@
package com.composea11yscanner.core.model
/**
- * Packed ARGB color stored as a 64-bit long.
+ * Packed ARGB color stored as a 64-bit value.
*
* The bit layout matches Compose's Color.value (each channel 16 bits: alpha, red, green, blue).
* Conversion from a Compose Color in :scanner-ui is zero-cost:
* Color(composeColor.value.toLong())
*/
@JvmInline
-value class Color(val value: Long) {
+value class Color(
+ /** Packed color value compatible with Compose color storage. */
+ val value: Long,
+) {
companion object {
+ /** Sentinel value used when a color could not be sampled. */
val Unspecified = Color(0L)
}
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt
index ed1d374..2602234 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/DpSize.kt
@@ -1,12 +1,18 @@
package com.composea11yscanner.core.model
/**
- * Density-independent size (dp units). Mirrors the Compose DpSize API
+ * Density-independent size in dp units. Mirrors the Compose DpSize API
* without pulling in the Compose dependency.
+ *
+ * @property width Width in dp.
+ * @property height Height in dp.
*/
data class DpSize(val width: Float, val height: Float) {
companion object {
+ /** A zero-width and zero-height size. */
val Zero = DpSize(0f, 0f)
+
+ /** Sentinel used when a size could not be measured. */
val Unspecified = DpSize(Float.NaN, Float.NaN)
}
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt
index 5cfacf6..0087d24 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/Rect.kt
@@ -3,6 +3,11 @@ package com.composea11yscanner.core.model
/**
* Immutable pixel-coordinate bounding box in screen space.
* Android screen coordinates: origin top-left, y increases downward.
+ *
+ * @property left Left edge in pixels.
+ * @property top Top edge in pixels.
+ * @property right Right edge in pixels.
+ * @property bottom Bottom edge in pixels.
*/
data class Rect(
val left: Int,
@@ -10,12 +15,17 @@ data class Rect(
val right: Int,
val bottom: Int,
) {
+ /** Width in pixels. */
val width: Int get() = right - left
+
+ /** Height in pixels. */
val height: Int get() = bottom - top
+ /** Returns true when either dimension is zero or negative. */
fun isEmpty(): Boolean = width <= 0 || height <= 0
companion object {
+ /** Empty rectangle at the origin. */
val Zero = Rect(0, 0, 0, 0)
}
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt
index e749da9..7f6a218 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScanResult.kt
@@ -1,5 +1,15 @@
package com.composea11yscanner.core.model
+/**
+ * Final outcome of a completed accessibility scan.
+ *
+ * @property scanId Unique id for this scan run.
+ * @property timestamp Wall-clock timestamp in milliseconds when the result was created.
+ * @property totalNodes Number of nodes considered by the scanner.
+ * @property issues Issues produced by enabled rules.
+ * @property passedRules Number of enabled rules that produced no issues.
+ * @property failedRules Number of enabled rules that produced at least one issue.
+ */
data class ScanResult(
val scanId: String,
val timestamp: Long,
@@ -8,10 +18,16 @@ data class ScanResult(
val passedRules: Int,
val failedRules: Int,
) {
+ /** Number of error-severity issues in [issues]. */
val errorCount: Int get() = issues.count { it.severity == A11ySeverity.Error }
+
+ /** Number of warning-severity issues in [issues]. */
val warningCount: Int get() = issues.count { it.severity == A11ySeverity.Warning }
+
+ /** Number of info-severity issues in [issues]. */
val infoCount: Int get() = issues.count { it.severity == A11ySeverity.Info }
+ /** True when at least one error-severity issue was found. */
val hasErrors: Boolean get() = errorCount > 0
/**
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
index 9dc96f0..1f8bd51 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerConfig.kt
@@ -1,5 +1,14 @@
package com.composea11yscanner.core.model
+/**
+ * Runtime configuration used by the scanner engine and UI integration.
+ *
+ * @property enabledRules Rule ids that should be evaluated.
+ * @property minTouchTargetDp Minimum touch target size in dp.
+ * @property minContrastRatio Minimum text contrast ratio used by contrast-related rules.
+ * @property debugOverlay Whether scanner UI should display issue overlays.
+ * @property autoScan Whether scanning should start automatically when the scanner attaches.
+ */
data class ScannerConfig(
val enabledRules: Set,
val minTouchTargetDp: Int = 48,
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt
index 3297f7c..f4713e4 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/ScannerState.kt
@@ -1,11 +1,28 @@
package com.composea11yscanner.core.model
+/** State emitted while a scan is idle, running, complete, or failed. */
sealed interface ScannerState {
+ /** No scan is currently active and no result is displayed. */
data object Idle : ScannerState
+ /**
+ * Scan progress state.
+ *
+ * @property progress Completion fraction from 0.0 to 1.0.
+ */
data class Scanning(val progress: Float) : ScannerState
+ /**
+ * Successful scan completion.
+ *
+ * @property result Full scan result.
+ */
data class Complete(val result: ScanResult) : ScannerState
+ /**
+ * Scan failure.
+ *
+ * @property message Human-readable error message.
+ */
data class Error(val message: String) : ScannerState
}
diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt
index 9f6f2b9..78a15fb 100644
--- a/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt
+++ b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt
@@ -4,15 +4,36 @@ import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
+/** Contract implemented by every accessibility rule. */
interface A11yRule {
+ /** Stable id used in configuration, reports, and issue ids. */
val ruleId: String
+
+ /** Human-readable rule name shown in reports. */
val ruleName: String
+
+ /** Severity assigned to issues produced by this rule. */
val severity: A11ySeverity
+
+ /** Optional WCAG criterion associated with this rule. */
val wcagReference: String?
+ /**
+ * Evaluates a single node.
+ *
+ * @param node Node to evaluate.
+ * @return Issue when the node violates the rule, otherwise null.
+ */
fun evaluate(node: A11yNode): A11yIssue?
- /** Evaluates an entire node list. Per-node rules are handled by the default implementation. */
+ /**
+ * Evaluates an entire node list.
+ *
+ * Per-node rules are handled by the default implementation.
+ *
+ * @param nodes Nodes to evaluate.
+ * @return Issues produced by this rule.
+ */
fun evaluateAll(nodes: List): List = nodes.mapNotNull { evaluate(it) }
}
@@ -22,10 +43,13 @@ interface A11yRule {
*/
abstract class BaseScanRule : A11yRule {
+ /** Always returns null because scan-level rules evaluate node lists. */
final override fun evaluate(node: A11yNode): A11yIssue? = null
+ /** Evaluates the complete node list. */
abstract override fun evaluateAll(nodes: List): List
+ /** Builds an [A11yIssue] with all rule-level fields pre-filled. */
protected fun issue(node: A11yNode, message: String, howToFix: String): A11yIssue =
A11yIssue(
issueId = "${ruleId}_${node.nodeId}",
@@ -39,6 +63,7 @@ abstract class BaseScanRule : A11yRule {
)
}
+/** Base class for rules that evaluate each node independently. */
abstract class BaseA11yRule : A11yRule {
/**
diff --git a/scanner-rules/consumer-rules.pro b/scanner-rules/consumer-rules.pro
index e69de29..e1a1e98 100644
--- a/scanner-rules/consumer-rules.pro
+++ b/scanner-rules/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class com.composea11yscanner.rules.** { *; }
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
index d5b764b..1d6c515 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt
@@ -6,13 +6,22 @@ import com.composea11yscanner.core.model.A11yRole
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule
+/** Flags clickable nodes that do not expose an accessibility role. */
class ClickableRoleRule : BaseA11yRule() {
+ /** Stable id for the clickable role rule. */
override val ruleId = "clickable-role"
+
+ /** Human-readable rule name. */
override val ruleName = "Clickable Role"
+
+ /** Severity assigned to missing roles. */
override val severity = A11ySeverity.Error
+
+ /** WCAG criterion associated with name, role, and value. */
override val wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)"
+ /** Evaluates a single node for role metadata. */
override fun check(node: A11yNode): A11yIssue? {
if (node.isMergedDescendant) return null
if (!node.isTouchTarget) return null
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
index 05521c2..29bdc70 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt
@@ -5,13 +5,22 @@ import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule
+/** Flags sibling-level nodes that reuse the same non-empty content description. */
class DuplicateContentDescriptionRule : BaseScanRule() {
+ /** Stable id for the duplicate content description rule. */
override val ruleId = "duplicate-content-description"
+
+ /** Human-readable rule name. */
override val ruleName = "Duplicate Content Description"
+
+ /** Severity assigned to duplicate labels. */
override val severity = A11ySeverity.Warning
+
+ /** WCAG criterion associated with distinguishable labels. */
override val wcagReference = "WCAG 2.4.6 Headings and Labels (Level AA)"
+ /** Evaluates all nodes together to find repeated labels at the same depth. */
override fun evaluateAll(nodes: List): List =
nodes
.filter { !it.contentDescription.isNullOrBlank() && !it.isMergedDescendant }
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
index 729dd96..e282493 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt
@@ -7,6 +7,8 @@ import com.composea11yscanner.core.rule.BaseScanRule
import kotlin.math.roundToInt
/**
+ * Flags focus traversal that jumps upward relative to visual top-to-bottom order.
+ *
* @param screenDensity Display density from DisplayMetrics.density.
* Used to convert [jumpThresholdDp] to pixels for comparison against [A11yNode.bounds].
* @param jumpThresholdDp Upward movement that triggers a violation (default 8dp).
@@ -16,13 +18,21 @@ class FocusOrderRule(
private val jumpThresholdDp: Float = 8f,
) : BaseScanRule() {
+ /** Stable id for the focus order rule. */
override val ruleId = "focus-order"
+
+ /** Human-readable rule name. */
override val ruleName = "Focus Order"
+
+ /** Severity assigned to focus order jumps. */
override val severity = A11ySeverity.Error
+
+ /** WCAG criterion associated with focus order. */
override val wcagReference = "WCAG 2.4.3 Focus Order (Level A)"
private val jumpThresholdPx: Int = (jumpThresholdDp * screenDensity).roundToInt()
+ /** Evaluates all focusable nodes in semantics order. */
override fun evaluateAll(nodes: List): List =
effectiveFocusNodes(nodes)
.zipWithNext { prev, curr ->
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt
index d3f92b4..601b439 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt
@@ -6,6 +6,8 @@ import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule
/**
+ * Flags text nodes that significantly overlap image nodes.
+ *
* @param overlapThreshold Fraction of the text node's area that must intersect an image
* node before the pair is flagged (default 0.5 = 50%).
*/
@@ -13,11 +15,19 @@ class ImageWithTextOverlayRule(
private val overlapThreshold: Float = 0.5f,
) : BaseScanRule() {
+ /** Stable id for the image text overlay rule. */
override val ruleId = "image-text-overlay"
+
+ /** Human-readable rule name. */
override val ruleName = "Image With Text Overlay"
+
+ /** Severity assigned to possible image/text contrast risk. */
override val severity = A11ySeverity.Warning
+
+ /** WCAG criterion associated with contrast. */
override val wcagReference = "WCAG 1.4.3 Contrast Minimum (Level AA)"
+ /** Evaluates all text and image nodes together to find overlaps. */
override fun evaluateAll(nodes: List): List {
val textNodes = nodes.filter { it.composableName.contains("Text", ignoreCase = true) }
val imageNodes = nodes.filter { it.composableName.contains("Image", ignoreCase = true) }
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt
index 9762a56..8c48b0b 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt
@@ -5,13 +5,22 @@ import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule
+/** Flags interactive or image-like nodes that do not expose a content description. */
class MissingContentDescriptionRule : BaseA11yRule() {
+ /** Stable id for the missing content description rule. */
override val ruleId = "missing-content-description"
+
+ /** Human-readable rule name. */
override val ruleName = "Missing Content Description"
+
+ /** Severity assigned to missing labels. */
override val severity = A11ySeverity.Error
+
+ /** WCAG criterion associated with non-text content. */
override val wcagReference = "WCAG 1.1.1 Non-text Content (Level A)"
+ /** Evaluates a single node for a screen-reader label. */
override fun check(node: A11yNode): A11yIssue? {
// Merged descendants are announced via their parent — skip them.
if (node.isMergedDescendant) return null
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
index bc8ab66..ffb4250 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ScannerRules.kt
@@ -3,9 +3,12 @@ package com.composea11yscanner.rules
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.core.rule.A11yRule
+/** Factory and metadata for the bundled scanner rule set. */
object ScannerRules {
+ /** Version of the bundled rule set. */
const val VERSION = "0.1.0"
+ /** Returns every built-in rule id understood by [buildRules]. */
fun allRuleIds(): List = listOf(
"touch-target-size",
"missing-content-description",
@@ -17,8 +20,11 @@ object ScannerRules {
)
/**
- * Builds the list of rules enabled by [config], wiring density-dependent rules with
- * [screenDensity] (from DisplayMetrics.density — must not be zero).
+ * Builds the list of rules enabled by [config], wiring density-dependent rules with [screenDensity].
+ *
+ * @param config Scanner configuration containing enabled rule ids and thresholds.
+ * @param screenDensity Display density used by pixel/dp conversion rules. Must not be zero.
+ * @return Rule instances that should run for the provided configuration.
*/
fun buildRules(config: ScannerConfig, screenDensity: Float): List = buildList {
if ("touch-target-size" in config.enabledRules)
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
index 64c256a..d218a33 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt
@@ -7,19 +7,29 @@ import com.composea11yscanner.core.rule.BaseScanRule
import kotlin.math.roundToInt
/**
+ * Flags text that may clip when simulated at a larger font scale.
+ *
* @param screenDensity Display density from DisplayMetrics.density.
- * @param scaleFactor Font scale to simulate (default 1.3×).
+ * @param scaleFactor Font scale to simulate (default 1.3x).
*/
class TextScalingRule(
private val screenDensity: Float,
private val scaleFactor: Float = 1.3f,
) : BaseScanRule() {
+ /** Stable id for the text scaling rule. */
override val ruleId = "text-scaling"
+
+ /** Human-readable rule name. */
override val ruleName = "Text Scaling"
+
+ /** Severity assigned to possible text clipping. */
override val severity = A11ySeverity.Warning
+
+ /** WCAG criterion associated with resized text. */
override val wcagReference = "WCAG 1.4.4 Resize Text (Level AA)"
+ /** Evaluates text nodes against their parent bounds at the configured scale factor. */
override fun evaluateAll(nodes: List): List =
nodes
.filter {
@@ -40,7 +50,7 @@ class TextScalingRule(
issue(
node = textNode,
- message = "'${textNode.composableName}' may clip at ${scaleFactor}× font scale: " +
+ message = "'${textNode.composableName}' may clip at ${scaleFactor}x font scale: " +
"height grows from ${originalDp}dp to ${scaledDp}dp, " +
"overflowing its container by ${overflowDp}dp.",
howToFix = "Remove fixed heights from the parent container, use " +
diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
index f9c7df4..254f814 100644
--- a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
+++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt
@@ -5,14 +5,27 @@ import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule
+/**
+ * Flags clickable nodes whose measured touch target is smaller than the configured minimum.
+ *
+ * @param minTouchTargetDp Minimum accepted width and height in dp.
+ */
class TouchTargetRule(
private val minTouchTargetDp: Int = 48,
) : BaseA11yRule() {
+ /** Stable id for the touch target size rule. */
override val ruleId = "touch-target-size"
+
+ /** Human-readable rule name. */
override val ruleName = "Touch Target Size"
+
+ /** Severity assigned to undersized touch targets. */
override val severity = A11ySeverity.Error
+
+ /** WCAG criterion associated with target size. */
override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)"
+ /** Evaluates a single node for touch target dimensions. */
override fun check(node: A11yNode): A11yIssue? {
if (node.isMergedDescendant) return null
if (!node.isTouchTarget) return null
diff --git a/scanner-ui/consumer-rules.pro b/scanner-ui/consumer-rules.pro
index e69de29..9605242 100644
--- a/scanner-ui/consumer-rules.pro
+++ b/scanner-ui/consumer-rules.pro
@@ -0,0 +1 @@
+-keep public class com.composea11yscanner.** { *; }
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt
index cecb911..419e21f 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/A11yScannerInitializer.kt
@@ -21,6 +21,11 @@ import com.composea11yscanner.rules.ScannerRules
*/
class A11yScannerInitializer : Initializer {
+ /**
+ * Registers lifecycle callbacks that install [ComposeA11yScanner] for resumed activities.
+ *
+ * @param context Startup context supplied by AndroidX App Startup.
+ */
override fun create(context: Context) {
val appContext = context.applicationContext
if (!appContext.isDebuggable()) return
@@ -48,6 +53,11 @@ class A11yScannerInitializer : Initializer {
)
}
+ /**
+ * Returns other App Startup initializers that must run before this initializer.
+ *
+ * @return Empty list because the scanner has no initializer dependencies.
+ */
override fun dependencies(): List>> = emptyList()
private fun Context.isDebuggable(): Boolean =
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
index 76aab1d..d4cd275 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ComposeA11yScanner.kt
@@ -91,6 +91,8 @@ object ComposeA11yScanner {
*
* Must be called on the main thread, typically in `Activity.onCreate` after `setContent`.
*
+ * @param activity Activity that should receive the scanner overlay.
+ * @param config Scanner configuration applied to this install.
* @throws IllegalStateException in non-debug builds.
*/
fun install(
@@ -131,6 +133,7 @@ object ComposeA11yScanner {
*
* Must be called on the main thread.
*
+ * @param activity Activity whose scanner overlay should be removed.
* @throws IllegalStateException in non-debug builds.
*/
fun uninstall(activity: ComponentActivity) {
@@ -255,7 +258,8 @@ object ComposeA11yScanner {
/**
* Internal composable rendered inside the overlay [ComposeView] that [ComposeA11yScanner.install]
- * adds on top of the activity's content. Mirrors the layer structure of [A11yScannerScaffold]
+ * adds on top of the activity's content. Mirrors the layer structure of
+ * [com.composea11yscanner.ui.A11yScannerScaffold]
* without re-wrapping the host content.
*/
@Composable
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt
index 60a2e29..191025b 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/export/ScanResultExporter.kt
@@ -8,8 +8,15 @@ import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
import java.util.Locale
+/** Exports scan results to text formats suitable for sharing or CI artifacts. */
object ScanResultExporter {
+ /**
+ * Converts [result] to a JSON string.
+ *
+ * @param result Result to export.
+ * @return JSON representation of the result.
+ */
fun exportToJson(result: ScanResult): String = buildString {
appendLine("{")
appendJsonField("scanId", result.scanId, indent = 2, trailingComma = true)
@@ -40,6 +47,12 @@ object ScanResultExporter {
append("}")
}
+ /**
+ * Converts [result] to a Markdown report.
+ *
+ * @param result Result to export.
+ * @return Markdown table and summary text.
+ */
fun exportToMarkdown(result: ScanResult): String = buildString {
appendLine("# Compose Accessibility Scan Report")
appendLine()
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt b/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt
index a3335b1..722e910 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/triggers/ScanTriggerExtensions.kt
@@ -20,6 +20,10 @@ import com.composea11yscanner.ComposeA11yScanner
*
* Kept as a consumer-side convenience extension in `:scanner-ui`; scanner core has no
* dependency on gestures, sensors, Android framework callbacks, or Compose modifiers.
+ *
+ * @param enabled Whether long-press scanning is active.
+ * @param onScanRequested Callback invoked after a long press.
+ * @return Modifier with the long-press scanner trigger installed.
*/
fun Modifier.scanOnLongPress(
enabled: Boolean = true,
@@ -36,6 +40,11 @@ fun Modifier.scanOnLongPress(
*
* Call from a composable screen that wants shake-triggered scans. If no accelerometer is
* present, this quietly does nothing.
+ *
+ * @param enabled Whether shake scanning is active.
+ * @param shakeThresholdG Required acceleration force in Gs.
+ * @param minTriggerIntervalMillis Minimum time between scan triggers.
+ * @param onScanRequested Callback invoked after a qualifying shake.
*/
@Composable
fun scanOnShake(
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
index 3363d37..405b748 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt
@@ -20,12 +20,17 @@ import kotlin.math.roundToInt
* Use the unmerged tree to see all rendered nodes including merged descendants:
* - Test: `composeTestRule.onRoot(useUnmergedTree = true).fetchSemanticsNode()`
* - Production: `SemanticsOwner.rootSemanticsNode` via [extract(SemanticsOwner)]
+ *
+ * @param density Density used to convert pixel bounds into dp sizes.
*/
class A11yNodeExtractor(private val density: Density) {
/**
* Recursively extracts all nodes from the tree rooted at [rootNode].
* Returns a flat list in depth-first order.
+ *
+ * @param rootNode Root semantics node to walk.
+ * @return Flattened accessibility nodes.
*/
fun extract(rootNode: SemanticsNode): List {
val result = mutableListOf()
@@ -36,6 +41,9 @@ class A11yNodeExtractor(private val density: Density) {
/**
* Entry point for production use. Requires opting in to the internal Compose UI API
* needed to access [SemanticsOwner].
+ *
+ * @param owner Semantics owner to extract from.
+ * @return Flattened accessibility nodes.
*/
@OptIn(InternalComposeUiApi::class)
fun extract(owner: SemanticsOwner): List = extract(owner.rootSemanticsNode)
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt
index 352a3b1..5e5dea0 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScanner.kt
@@ -20,11 +20,21 @@ class A11yScanner(
private val rules: List,
private val density: Density,
) {
- /** Scans from a semantics node (test or manual tree walk). */
+ /**
+ * Scans from a semantics node, typically in tests or manual tree walks.
+ *
+ * @param rootNode Root semantics node to scan.
+ * @return Completed scan result.
+ */
fun scan(rootNode: SemanticsNode): ScanResult =
buildResult(A11yNodeExtractor(density).extract(rootNode))
- /** Scans the live Compose semantics tree via [SemanticsOwner]. */
+ /**
+ * Scans the live Compose semantics tree via [SemanticsOwner].
+ *
+ * @param owner Semantics owner for the Compose tree.
+ * @return Completed scan result.
+ */
@OptIn(InternalComposeUiApi::class)
fun scan(owner: SemanticsOwner): ScanResult =
buildResult(A11yNodeExtractor(density).extract(owner))
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
index 415276c..4f74b7f 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerController.kt
@@ -41,7 +41,7 @@ import kotlinx.coroutines.withContext
*
* @param nodeProvider Called once per [startScan] invocation to produce the node list.
* Must be safe to call on [Dispatchers.Default].
- * @param screenDensity [DisplayMetrics.density] — forwarded to density-dependent rules.
+ * @param screenDensity DisplayMetrics.density, forwarded to density-dependent rules.
*/
class A11yScannerController(
private val nodeProvider: () -> List,
@@ -75,7 +75,12 @@ class A11yScannerController(
// --- Builder methods ---
- /** Replaces the active configuration. Returns [this] for chaining. */
+ /**
+ * Replaces the active scanner configuration.
+ *
+ * @param config Configuration used for future scans.
+ * @return This controller for chaining.
+ */
fun configure(config: ScannerConfig): A11yScannerController = apply {
this.config = config
}
@@ -83,7 +88,10 @@ class A11yScannerController(
/**
* Appends [rules] to the set that will run alongside the standard rule set.
* Custom rules are automatically added to [ScannerConfig.enabledRules] so the
- * engine never filters them out. Returns [this] for chaining.
+ * engine never filters them out. Returns this controller for chaining.
+ *
+ * @param rules Custom rules to append.
+ * @return This controller for chaining.
*/
fun withRules(vararg rules: A11yRule): A11yScannerController = apply {
customRules += rules
@@ -95,7 +103,7 @@ class A11yScannerController(
* Cancels any in-progress scan, then starts a new one.
*
* Returns a [Flow] backed by a [MutableSharedFlow] — multiple collectors are safe,
- * and late subscribers receive the most recent state immediately via [replay].
+ * and late subscribers receive the most recent state immediately via replay.
*
* Emission sequence: Scanning(0f) → Scanning(k/n) … → Scanning(1f) → Complete(result)
* or Complete(emptyResult) if there are no enabled rules or no nodes.
@@ -126,6 +134,7 @@ class A11yScannerController(
return _state.asSharedFlow()
}
+ /** Stops any active scan and emits [ScannerState.Idle]. */
fun clearState() {
stopScan()
_state.tryEmit(ScannerState.Idle)
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
index 1a43e2b..6544080 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/A11yScannerScaffold.kt
@@ -48,6 +48,12 @@ import com.composea11yscanner.core.model.ScannerState
* A new scan starts automatically when the scaffold enters composition and again
* whenever [config] changes. The in-flight scan is stopped when the scaffold
* leaves composition.
+ *
+ * @param scannerController Controller that runs scans and exposes scanner state.
+ * @param config Scanner configuration applied to the controller.
+ * @param modifier Modifier applied to the root scaffold.
+ * @param issueOffsetY Vertical offset applied to issue highlights.
+ * @param content Host UI content being scanned.
*/
@Composable
fun A11yScannerScaffold(
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt
index 6e3f52d..d5f2a80 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ColorExtractor.kt
@@ -15,20 +15,34 @@ import com.composea11yscanner.core.model.Rect
* candidates, so a solid background yields a single-element list.
* - Text/foreground: single pixel at the node's geometric center.
*
- * [Rect] coordinates must be root-relative (matching [SemanticsNode.boundsInRoot]).
+ * [Rect] coordinates must be root-relative, matching Compose semantics bounds.
* [rootView] must be laid out with non-zero dimensions before calling [extractColors].
*
* Note: [drawToBitmap] captures a software copy of the entire view on every call.
* When sampling multiple nodes, prefer creating the bitmap once externally and calling
* [sampleEdges]/[sampleCenter] directly with the shared [Bitmap].
+ *
+ * @param rootView View whose rendered pixels should be sampled.
*/
class ColorExtractor(private val rootView: View) {
+ /**
+ * Colors sampled from a node's rendered bounds.
+ *
+ * @property backgroundColors Distinct colors sampled along the node edges.
+ * @property textColor Color sampled at the center point, if within the bitmap.
+ */
data class Result(
val backgroundColors: List,
val textColor: Color?,
)
+ /**
+ * Captures [rootView] and samples colors inside [bounds].
+ *
+ * @param bounds Root-relative node bounds.
+ * @return Sampled colors for the node.
+ */
fun extractColors(bounds: Rect): Result {
if (bounds.isEmpty()) return Result(emptyList(), null)
val bitmap = rootView.drawToBitmap()
@@ -42,6 +56,13 @@ class ColorExtractor(private val rootView: View) {
}
}
+ /**
+ * Samples corners and edge midpoints from [bounds] in [bitmap].
+ *
+ * @param bitmap Bitmap to sample.
+ * @param bounds Pixel bounds to sample.
+ * @return Distinct sampled colors.
+ */
fun sampleEdges(bitmap: Bitmap, bounds: Rect): List {
val l = bounds.left
val t = bounds.top
@@ -64,6 +85,13 @@ class ColorExtractor(private val rootView: View) {
.distinct()
}
+ /**
+ * Samples the center pixel from [bounds] in [bitmap].
+ *
+ * @param bitmap Bitmap to sample.
+ * @param bounds Pixel bounds to sample.
+ * @return Center color, or null when outside the bitmap.
+ */
fun sampleCenter(bitmap: Bitmap, bounds: Rect): Color? {
val cx = (bounds.left + bounds.right) / 2
val cy = (bounds.top + bounds.bottom) / 2
@@ -79,7 +107,7 @@ class ColorExtractor(private val rootView: View) {
}
/**
- * Converts an Android ARGB [ColorInt] to [Color] using Compose's Color.value packing:
+ * Converts an Android ARGB color int to [Color] using Compose's Color.value packing:
* value = (argb & 0xFFFFFFFFL) shl 32
* This keeps the sRGB color space ID (0) in bits 0-5 as zero, matching [Color.Unspecified]'s
* convention and making round-trips with Compose's Color(Int) constructor lossless.
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt
index ce53ea4..78d54ad 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueDetailPanel.kt
@@ -90,6 +90,18 @@ private fun String.toWcagUrl(): String {
/**
* Slides up from the bottom when [issue] becomes non-null; slides back down on dismissal.
* Content is retained during the exit transition so the panel doesn't flash empty.
+ *
+ * @param issue Primary issue to display, or null to hide the panel.
+ * @param issues Issues associated with the selected node.
+ * @param onDismiss Callback invoked when the panel close action is tapped.
+ * @param modifier Modifier applied to the panel container.
+ */
+/**
+ * Convenience overload for showing one or more issues.
+ *
+ * @param issues Issues associated with the selected node.
+ * @param onDismiss Callback invoked when the panel close action is tapped.
+ * @param modifier Modifier applied to the panel container.
*/
@Composable
fun IssueDetailPanel(
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt
index bc6838b..10c03c6 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueHighlightBox.kt
@@ -35,6 +35,13 @@ private fun A11ySeverity.toBorderColor(): Color = when (this) {
A11ySeverity.Info -> InfoBorderColor
}
+/**
+ * Draws a tappable outline over the bounds of an accessibility issue.
+ *
+ * @param issue Issue whose affected node bounds determine the highlight size.
+ * @param onIssueSelected Callback invoked when the highlight is tapped.
+ * @param modifier Modifier applied to the highlight spacer.
+ */
@Composable
fun IssueHighlightBox(
issue: A11yIssue,
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt
index 6bd3b7f..b63cfb2 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/IssueSeverityBadge.kt
@@ -56,6 +56,13 @@ private fun A11ySeverity.label(): String = when (this) {
A11ySeverity.Info -> "Info"
}
+/**
+ * Compact severity badge used in scan summaries and overlays.
+ *
+ * @param severity Severity represented by the badge color and icon.
+ * @param count Number of issues represented by the badge.
+ * @param modifier Modifier applied to the badge row.
+ */
@Composable
fun IssueSeverityBadge(
severity: A11ySeverity,
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
index cd536a8..a7c6664 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanReportSheet.kt
@@ -96,6 +96,10 @@ private fun Float.toScoreColor(): Color = when {
/**
* Full-screen report sheet. Pressing back/dismiss while a detail panel is open
* closes the panel first; a second press dismisses the sheet.
+ *
+ * @param result Scan result displayed in the report.
+ * @param onDismiss Callback invoked when the sheet should close.
+ * @param modifier Modifier applied to the modal sheet.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt
index 4d3c101..0f58fd6 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt
@@ -1,31 +1,18 @@
package com.composea11yscanner.ui
import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.RepeatMode
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.animateFloat
-import androidx.compose.animation.core.infiniteRepeatable
-import androidx.compose.animation.core.rememberInfiniteTransition
-import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
-import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBars
-import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
@@ -46,16 +33,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.graphics.drawscope.Stroke
-import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
@@ -65,86 +45,82 @@ import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
import com.composea11yscanner.core.model.ScannerState
+// ─────────────────────────────────────────────────────────────────────────────
+// Colour constants
+// ─────────────────────────────────────────────────────────────────────────────
+
private val ErrorColor = Color(0xFFD32F2F)
private val WarningColor = Color(0xFFFFA000)
private val InfoColor = Color(0xFF1976D2)
-private val ScoreColor = Color(0xFF6C63FF)
-private val PrimaryGlow = Color(0x556C63FF)
-private val PillShape = RoundedCornerShape(percent = 50)
-private val ScanningPillWidth = 220.dp
-private val CompletePillWidth = 280.dp
-private val DefaultTopOffset = 72.dp
+private val ScoreGoodColor = Color(0xFF2E7D32) // ≥ 90 %
+private val ScoreFairColor = Color(0xFFF57C00) // ≥ 70 %
+private val ScorePoorColor = Color(0xFFC62828) // < 70 %
+
+private fun Float.toScoreColor(): Color = when {
+ this >= 90f -> ScoreGoodColor
+ this >= 70f -> ScoreFairColor
+ else -> ScorePoorColor
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Public composable
+// ─────────────────────────────────────────────────────────────────────────────
+/**
+ * Top bar that shows scan progress while a scan is running and a summary of
+ * findings once it completes. Tapping the score chip opens [ScanReportSheet].
+ *
+ * Callers are responsible for showing/hiding the bar itself; this composable
+ * renders content for [ScannerState.Scanning] and [ScannerState.Complete] and
+ * nothing for [ScannerState.Idle] and [ScannerState.Error].
+ *
+ * @param state Scanner state to summarize.
+ * @param modifier Modifier applied to the summary bar.
+ */
@Composable
fun ScanSummaryBar(
state: ScannerState,
modifier: Modifier = Modifier,
- topOffset: Dp = DefaultTopOffset,
) {
var showReport by remember { mutableStateOf(false) }
- var lastResult by remember { mutableStateOf(null) }
+ // Retain last complete result so the sheet stays populated when state rolls
+ // back to Idle while the sheet is still open.
+ var lastResult by remember { mutableStateOf(null) }
LaunchedEffect(state) {
if (state is ScannerState.Complete) lastResult = state.result
}
- val targetWidth = when (state) {
- is ScannerState.Complete -> CompletePillWidth
- is ScannerState.Scanning -> ScanningPillWidth
- is ScannerState.Error -> CompletePillWidth
- ScannerState.Idle -> 120.dp
- }
- val pillWidth by animateDpAsState(
- targetValue = targetWidth,
- animationSpec = tween(durationMillis = 360),
- label = "scan-summary-width",
- )
-
- Box(
- modifier = modifier
- .fillMaxWidth()
- // topOffset clears the host toolbar; the inset additionally clears the system bar.
- .windowInsetsPadding(WindowInsets.statusBars)
- .padding(top = topOffset, start = 16.dp, end = 16.dp),
- contentAlignment = Alignment.TopCenter,
+ Surface(
+ tonalElevation = 2.dp,
+ modifier = modifier.fillMaxWidth(),
) {
- Surface(
- color = MaterialTheme.colorScheme.surface,
- shape = PillShape,
- tonalElevation = 6.dp,
- modifier = Modifier
- .width(pillWidth)
- .shadow(
- elevation = 16.dp,
- shape = PillShape,
- ambientColor = PrimaryGlow,
- spotColor = PrimaryGlow,
- ),
- ) {
- AnimatedContent(
- targetState = state,
- transitionSpec = { fadeIn() togetherWith fadeOut() },
- contentKey = { current ->
- when (current) {
- is ScannerState.Scanning -> "scanning"
- is ScannerState.Complete -> "complete"
- is ScannerState.Error -> "error"
- ScannerState.Idle -> "idle"
- }
- },
- label = "scan-state",
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
- ) { currentState ->
- when (currentState) {
- is ScannerState.Scanning -> ScanningContent(progress = currentState.progress)
- is ScannerState.Complete -> ScanCompleteContent(
- result = currentState.result,
- onScoreClick = { showReport = true },
- )
- is ScannerState.Error -> ErrorContent(message = currentState.message)
- ScannerState.Idle -> Unit
+ // contentKey groups Scanning(0.1f) and Scanning(0.9f) under the same
+ // key so the progress bar isn't cross-faded on every progress tick —
+ // the fade only fires on state-category transitions (e.g. Scanning →
+ // Complete).
+ AnimatedContent(
+ targetState = state,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ contentKey = { s ->
+ when (s) {
+ is ScannerState.Scanning -> "scanning"
+ is ScannerState.Complete -> "complete"
+ else -> "other"
}
+ },
+ label = "scan-state",
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+ ) { currentState ->
+ when (currentState) {
+ is ScannerState.Scanning -> ScanningContent(progress = currentState.progress)
+ is ScannerState.Complete -> ScanCompleteContent(
+ result = currentState.result,
+ onScoreClick = { showReport = true },
+ )
+ is ScannerState.Error -> ErrorContent(message = currentState.message)
+ ScannerState.Idle -> Unit
}
}
}
@@ -159,22 +135,21 @@ fun ScanSummaryBar(
}
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Bar content slots
+// ─────────────────────────────────────────────────────────────────────────────
+
@Composable
private fun ScanningContent(progress: Float) {
- Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- RadarSweepIcon(
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(22.dp),
- )
Text(
- text = "Scanning...",
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurface,
+ text = "Scanning accessibility…",
+ style = MaterialTheme.typography.bodyMedium,
)
Text(
text = "${(progress * 100).toInt()}%",
@@ -184,12 +159,7 @@ private fun ScanningContent(progress: Float) {
}
LinearProgressIndicator(
progress = { progress },
- modifier = Modifier
- .fillMaxWidth()
- .height(3.dp)
- .clip(PillShape),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.16f),
+ modifier = Modifier.fillMaxWidth(),
)
}
}
@@ -204,24 +174,33 @@ private fun ScanCompleteContent(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- CountChip(
- count = result.errorCount,
- color = ErrorColor,
- icon = Icons.Filled.Error,
- contentDescription = "${result.errorCount} errors",
- )
- CountChip(
- count = result.warningCount,
- color = WarningColor,
- icon = Icons.Filled.Warning,
- contentDescription = "${result.warningCount} warnings",
- )
- CountChip(
- count = result.infoCount,
- color = InfoColor,
- icon = Icons.Filled.Info,
- contentDescription = "${result.infoCount} info items",
- )
+ if (result.errorCount > 0) {
+ CountChip(
+ count = result.errorCount,
+ color = ErrorColor,
+ icon = Icons.Filled.Error,
+ contentDescription = "${result.errorCount} errors",
+ )
+ }
+ if (result.warningCount > 0) {
+ CountChip(
+ count = result.warningCount,
+ color = WarningColor,
+ icon = Icons.Filled.Warning,
+ contentDescription = "${result.warningCount} warnings",
+ )
+ }
+ if (result.infoCount > 0) {
+ CountChip(
+ count = result.infoCount,
+ color = InfoColor,
+ icon = Icons.Filled.Info,
+ contentDescription = "${result.infoCount} info items",
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
ScoreChip(score = result.overallScore, onClick = onScoreClick)
}
}
@@ -229,7 +208,6 @@ private fun ScanCompleteContent(
@Composable
private fun ErrorContent(message: String) {
Row(
- modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -243,58 +221,13 @@ private fun ErrorContent(message: String) {
text = "Scan failed: $message",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
- maxLines = 1,
)
}
}
-@Composable
-private fun RadarSweepIcon(
- color: Color,
- modifier: Modifier = Modifier,
-) {
- val isInspecting = LocalInspectionMode.current
- val infiniteTransition = rememberInfiniteTransition(label = "radar-sweep")
- val sweepRotation by infiniteTransition.animateFloat(
- initialValue = 0f,
- targetValue = 360f,
- animationSpec = infiniteRepeatable(
- animation = tween(durationMillis = 1100, easing = LinearEasing),
- repeatMode = RepeatMode.Restart,
- ),
- label = "radar-sweep-rotation",
- )
- val rotation = if (isInspecting) 35f else sweepRotation
-
- Canvas(modifier = modifier) {
- val strokeWidth = 1.5.dp.toPx()
- val radius = size.minDimension / 2f - strokeWidth
- val center = Offset(size.width / 2f, size.height / 2f)
-
- drawCircle(
- color = color.copy(alpha = 0.22f),
- radius = radius,
- center = center,
- style = Stroke(width = strokeWidth),
- )
- drawCircle(
- color = color.copy(alpha = 0.14f),
- radius = radius * 0.55f,
- center = center,
- style = Stroke(width = strokeWidth),
- )
- rotate(degrees = rotation, pivot = center) {
- drawLine(
- color = color,
- start = center,
- end = Offset(center.x, center.y - radius),
- strokeWidth = 2.dp.toPx(),
- cap = StrokeCap.Round,
- )
- }
- drawCircle(color = color, radius = 2.2.dp.toPx(), center = center)
- }
-}
+// ─────────────────────────────────────────────────────────────────────────────
+// Chip atoms used in the bar
+// ─────────────────────────────────────────────────────────────────────────────
@Composable
private fun CountChip(
@@ -305,10 +238,10 @@ private fun CountChip(
) {
Row(
modifier = Modifier
- .clip(PillShape)
+ .clip(RoundedCornerShape(4.dp))
.background(color)
- .padding(horizontal = 7.dp, vertical = 5.dp),
- horizontalArrangement = Arrangement.spacedBy(3.dp),
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@@ -329,27 +262,31 @@ private fun CountChip(
private fun ScoreChip(score: Float, onClick: () -> Unit) {
Row(
modifier = Modifier
- .clip(PillShape)
- .background(ScoreColor)
+ .clip(RoundedCornerShape(8.dp))
+ .background(score.toScoreColor())
.clickable(onClick = onClick)
- .padding(horizontal = 9.dp, vertical = 5.dp),
- horizontalArrangement = Arrangement.spacedBy(2.dp),
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "${score.toInt()}%",
color = Color.White,
- style = MaterialTheme.typography.labelMedium,
+ style = MaterialTheme.typography.labelLarge,
)
Icon(
imageVector = Icons.Filled.ExpandMore,
contentDescription = "View full report",
tint = Color.White,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(16.dp),
)
}
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Previews
+// ─────────────────────────────────────────────────────────────────────────────
+
@Preview(showBackground = true)
@Composable
private fun ScanSummaryBarScanningPreview() {
@@ -400,68 +337,45 @@ private fun previewResult(
failed: Int,
): ScanResult {
val node = A11yNode(
- nodeId = "node-1",
- composableName = "Button",
- bounds = Rect(0, 0, 300, 120),
- contentDescription = null,
- isTouchTarget = true,
- touchTargetSize = DpSize(100f, 40f),
- textColor = null,
- backgroundColors = emptyList(),
- isFocusable = true,
- isMergedDescendant = false,
- depth = 1,
+ nodeId = "node-1", composableName = "Button",
+ bounds = Rect(0, 0, 300, 120), contentDescription = null,
+ isTouchTarget = true, touchTargetSize = DpSize(100f, 40f),
+ textColor = null, backgroundColors = emptyList(),
+ isFocusable = true, isMergedDescendant = false, depth = 1,
)
val issues = buildList {
repeat(errors) {
add(
A11yIssue(
- issueId = "err-$it",
- severity = A11ySeverity.Error,
- ruleId = "missing-content-description",
- ruleName = "Missing Content Description",
- affectedNode = node,
- message = "Interactive element has no content description.",
- howToFix = "Add Modifier.semantics { contentDescription = \"Label\" }",
- wcagReference = "WCAG 1.1.1 Non-text Content (Level A)",
+ "err-$it", A11ySeverity.Error, "missing-content-description",
+ "Missing Content Description", node,
+ "Interactive element has no content description.",
+ "Add Modifier.semantics { contentDescription = … }", "WCAG 1.1.1 Non-text Content (Level A)",
)
)
}
repeat(warnings) {
add(
A11yIssue(
- issueId = "warn-$it",
- severity = A11ySeverity.Warning,
- ruleId = "focus-order",
- ruleName = "Focus Order",
- affectedNode = node,
- message = "Focus jumps upward unexpectedly.",
- howToFix = "Reorder composables top-to-bottom.",
- wcagReference = "WCAG 2.4.3 Focus Order (Level A)",
+ "warn-$it", A11ySeverity.Warning, "focus-order",
+ "Focus Order", node, "Focus jumps upward unexpectedly.",
+ "Reorder composables top-to-bottom.", "WCAG 2.4.3 Focus Order (Level A)",
)
)
}
repeat(info) {
add(
A11yIssue(
- issueId = "info-$it",
- severity = A11ySeverity.Info,
- ruleId = "text-scaling",
- ruleName = "Text Scaling",
- affectedNode = node,
- message = "Text does not scale with system font size.",
- howToFix = "Use sp units for all text sizes.",
- wcagReference = null,
+ "info-$it", A11ySeverity.Info, "text-scaling",
+ "Text Scaling", node, "Text does not scale with system font size.",
+ "Use sp units for all text sizes.", null,
)
)
}
}
return ScanResult(
- scanId = "preview",
- timestamp = 0L,
- totalNodes = 24,
- issues = issues,
- passedRules = passed,
- failedRules = failed,
+ scanId = "preview", timestamp = 0L,
+ totalNodes = 24, issues = issues,
+ passedRules = passed, failedRules = failed,
)
}
diff --git a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt
index e3f0d8f..df02bd7 100644
--- a/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt
+++ b/scanner-ui/src/main/java/com/composea11yscanner/ui/ScannerUi.kt
@@ -18,6 +18,18 @@ import com.composea11yscanner.core.model.DpSize
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
+/**
+ * Draws issue highlights over affected nodes from a scan result.
+ *
+ * Issues are grouped by affected node id so tapping one highlight can show every issue
+ * associated with that node.
+ *
+ * @param scanResult Result whose issues should be highlighted, or null to hide overlays.
+ * @param onIssueSelected Backward-compatible callback for a single selected issue.
+ * @param onIssuesSelected Callback invoked with all issues for the tapped node.
+ * @param modifier Modifier applied to the overlay container.
+ * @param issueOffsetY Vertical offset applied to highlighted bounds, usually for scroll correction.
+ */
@Composable
fun A11yIssueOverlay(
scanResult: ScanResult?,