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 + +![Annotated GIF showing the Compose A11y Scanner issue summary, view highlights, and issue detail sheet in the sample app](docs/overlay-demo.gif) + +## 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?,