Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ See [RULES.md](RULES.md) for complete behavior, fixes, WCAG references, and exam

| 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) |
Expand Down
36 changes: 5 additions & 31 deletions RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,18 @@

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`.
Implementation note: the current codebase exposes 6 built-in rules. 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",
)
}
```
| `clickable-role` | Clickable Role | Error | Clickable nodes with role-specific semantic requirements, currently clickable images without a content description. | Provide a meaningful label for clickable images. A generic clickable does not require a role when no predefined Compose role accurately applies. | WCAG 4.1.2 Name, Role, Value (Level A) |

## `missing-content-description` - Missing Content Description

Expand Down Expand Up @@ -196,9 +171,9 @@ Box {

**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.
**What it checks:** This rule reports clickable image nodes when their content description is empty. A generic clickable node is not reported merely because its role is null: Compose permits an unspecified role when none of its predefined roles accurately describes the element, such as a clickable list row.

**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.
**How to fix:** Provide a meaningful, non-empty content description for a clickable image. For controls with a well-defined role, use the corresponding semantic API—for example, `selectable(role = Role.Tab, ...)` for a custom tab—but do not assign `Role.Button` to generic click targets solely to satisfy this rule.

**WCAG reference:** WCAG 4.1.2 Name, Role, Value (Level A)

Expand All @@ -211,8 +186,7 @@ Box(
role = Role.Button
contentDescription = "Retry loading"
}
.clickable(onClick = onRetry)
.minimumInteractiveComponentSize(),
.clickable(onClick = onRetry),
) {
Text("Retry")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package com.composea11yscanner.sample
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.rules.ScannerRules
import com.composea11yscanner.sample.ui.theme.ScannerTheme
Expand Down Expand Up @@ -50,7 +49,6 @@ class FixedScreenAccessibilityTest {
val config = ScannerConfig(enabledRules = ScannerRules.allRuleIds().toSet())
val scanner = A11yScanner(
rules = ScannerRules.buildRules(config, screenDensity = 1f),
density = Density(1f),
)
val result = scanner.scan(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())

Expand Down
3 changes: 0 additions & 3 deletions sample/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ComposeA11yScanner">
<meta-data
android:name="a11y_scanner_min_touch_target"
android:value="48" />
<meta-data
android:name="a11y_scanner_min_contrast"
android:value="4.5" />
Expand Down
17 changes: 13 additions & 4 deletions sample/src/main/java/com/composea11yscanner/sample/LoginScreens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ fun BrokenLoginScreen(onViewFixed: (() -> Unit)? = null) {
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
SecureBankHeader()
SecureBankHeader(isFixed = false)
LoginIntro()

Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Expand Down Expand Up @@ -135,7 +135,7 @@ fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) {
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
SecureBankHeader()
SecureBankHeader(isFixed = true)
LoginIntro()

Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Expand Down Expand Up @@ -239,13 +239,22 @@ fun FixedLoginScreen(onViewBroken: (() -> Unit)? = null) {
}

@Composable
private fun SecureBankHeader() {
private fun SecureBankHeader(isFixed: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
BankLogoMark()
BankLogoMark(
modifier = if (isFixed) {
Modifier
.semantics { contentDescription = "Open SecureBank home" }
.clickable(role = Role.Image, onClick = {})
} else {
// Intentionally broken: an actionable image needs an accessible description.
Modifier.clickable(role = Role.Image, onClick = {})
},
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = "SecureBank",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ fun BrokenScreenCard(
}

@Composable
fun BankLogoMark() {
fun BankLogoMark(modifier: Modifier = Modifier) {
Box(
modifier = Modifier
modifier = modifier
.size(48.dp)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF6C63FF)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.unit.Density
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.ui.A11yNodeExtractor
Expand All @@ -33,7 +32,7 @@ internal fun ComponentActivity.extractBrokenSampleNodes(): List<A11yNode> =
?: sampleRoot.boundsInRoot.let {
Rect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt())
}
A11yNodeExtractor(Density(this))
A11yNodeExtractor()
.extract(sampleRoot)
.filterVisibleIn(viewport)
}.getOrDefault(emptyList())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package com.composea11yscanner.core.model
* @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.
Expand All @@ -22,7 +21,6 @@ data class A11yNode(
val bounds: Rect,
val contentDescription: String?,
val isTouchTarget: Boolean,
val touchTargetSize: DpSize,
val textColor: Color?,
val backgroundColors: List<Color>,
val isFocusable: Boolean,
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,12 @@ 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<String>,
val minTouchTargetDp: Int = 48,
val minContrastRatio: Float = 4.5f,
val debugOverlay: Boolean = true,
val autoScan: Boolean = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import app.cash.turbine.test
import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.model.DpSize
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScannerConfig
import com.composea11yscanner.core.model.ScannerState
Expand All @@ -29,7 +28,6 @@ class A11yScanEngineTest {
bounds = Rect(0, 0, 100, 100),
contentDescription = null,
isTouchTarget = false,
touchTargetSize = DpSize(48f, 48f),
textColor = null,
backgroundColors = emptyList(),
isFocusable = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11yRole
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.model.Color
import com.composea11yscanner.core.model.DpSize
import com.composea11yscanner.core.model.Rect
import com.composea11yscanner.core.model.ScanResult
import com.composea11yscanner.core.rule.BaseA11yRule
Expand All @@ -24,7 +23,6 @@ class CoreModelAndRuleTest {
bounds = Rect(0, 0, 100, 48),
contentDescription = "Submit",
isTouchTarget = true,
touchTargetSize = DpSize(100f, 48f),
textColor = Color(0xFF000000),
backgroundColors = emptyList(),
isFocusable = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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. */
/** Flags clickable images that do not expose an accessible description. */
class ClickableRoleRule : BaseA11yRule() {

/** Stable id for the clickable role rule. */
Expand All @@ -15,30 +15,26 @@ class ClickableRoleRule : BaseA11yRule() {
/** Human-readable rule name. */
override val ruleName = "Clickable Role"

/** Severity assigned to missing roles. */
/** Severity assigned to invalid clickable-role semantics. */
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. */
/** Evaluates role-specific requirements for a clickable node. */
override fun check(node: A11yNode): A11yIssue? {
if (node.isMergedDescendant) return null
if (!node.isTouchTarget) return null

val missingRole = node.role == null
val imageWithoutDescription =
node.role == A11yRole.Image && node.contentDescription.isNullOrBlank()

if (!missingRole && !imageWithoutDescription) return null
// Compose intentionally permits a null role when none of its predefined roles accurately
// describes a custom click target (for example, a clickable list row). Requiring a role for
// every OnClick node encourages misleading Role.Button semantics and creates false positives.
if (node.role != A11yRole.Image || !node.contentDescription.isNullOrBlank()) return null

return issue(
node = node,
message = "Clickable element has no semantic role. " +
"Add Modifier.semantics { role = Role.Button }",
howToFix = "Apply the appropriate role: Modifier.semantics { role = Role.Button } " +
"for buttons, Role.Checkbox for toggles, Role.Image for images. " +
"Clickable images also require a non-empty contentDescription.",
message = "Clickable image has no accessible description.",
howToFix = "Provide a meaningful, non-empty contentDescription for the clickable image.",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ object ScannerRules {

/** Returns every built-in rule id understood by [buildRules]. */
fun allRuleIds(): List<String> = listOf(
"touch-target-size",
"missing-content-description",
"duplicate-content-description",
"focus-order",
Expand All @@ -27,8 +26,6 @@ object ScannerRules {
* @return Rule instances that should run for the provided configuration.
*/
fun buildRules(config: ScannerConfig, screenDensity: Float): List<A11yRule> = buildList {
if ("touch-target-size" in config.enabledRules)
add(TouchTargetRule(config.minTouchTargetDp))
if ("missing-content-description" in config.enabledRules)
add(MissingContentDescriptionRule())
if ("duplicate-content-description" in config.enabledRules)
Expand Down

This file was deleted.

Loading
Loading