Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ data class A11yNode(
val isFocusable: Boolean,
val isMergedDescendant: Boolean,
val depth: Int,
val role: A11yRole? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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])
*/
enum class A11yRole {
Button,
Checkbox,
DropdownList,
Image,
RadioButton,
Switch,
Tab,
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ interface A11yRule {
val wcagReference: String?

fun evaluate(node: A11yNode): A11yIssue?

/** Evaluates an entire node list. Per-node rules are handled by the default implementation. */
fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> = nodes.mapNotNull { evaluate(it) }
}

/**
* Base for rules that must inspect all nodes together (e.g. duplicate detection).
* [evaluate] is sealed to a no-op; subclasses implement [evaluateAll] instead.
*/
abstract class BaseScanRule : A11yRule {

final override fun evaluate(node: A11yNode): A11yIssue? = null

abstract override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue>

protected fun issue(node: A11yNode, message: String, howToFix: String): A11yIssue =
A11yIssue(
issueId = "${ruleId}_${node.nodeId}",
severity = severity,
ruleId = ruleId,
ruleName = ruleName,
affectedNode = node,
message = message,
howToFix = howToFix,
wcagReference = wcagReference,
)
}

abstract class BaseA11yRule : A11yRule {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11yRole
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule

class ClickableRoleRule : BaseA11yRule() {

override val ruleId = "clickable-role"
override val ruleName = "Clickable Role"
override val severity = A11ySeverity.Error
override val wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)"

override fun check(node: A11yNode): A11yIssue? {
if (!node.isTouchTarget) return null

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

if (!missingRole && !imageWithoutDescription) 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.",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule

class DuplicateContentDescriptionRule : BaseScanRule() {

override val ruleId = "duplicate-content-description"
override val ruleName = "Duplicate Content Description"
override val severity = A11ySeverity.Warning
override val wcagReference = "WCAG 2.4.6 Headings and Labels (Level AA)"

override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> =
nodes
.filter { !it.contentDescription.isNullOrBlank() && !it.isMergedDescendant }
.groupBy { it.depth to it.contentDescription }
.filter { (_, group) -> group.size > 1 }
.flatMap { (key, group) ->
val text = key.second
group.map { node ->
issue(
node = node,
message = "Two elements share the same content description: '$text'. " +
"Screen readers may confuse users.",
howToFix = "Give each element a unique content description that " +
"identifies its specific action or content.",
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule
import kotlin.math.roundToInt

/**
* @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).
*/
class FocusOrderRule(
private val screenDensity: Float,
private val jumpThresholdDp: Float = 8f,
) : BaseScanRule() {

override val ruleId = "focus-order"
override val ruleName = "Focus Order"
override val severity = A11ySeverity.Error
override val wcagReference = "WCAG 2.4.3 Focus Order (Level A)"

private val jumpThresholdPx: Int = (jumpThresholdDp * screenDensity).roundToInt()

override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> =
nodes
.filter { it.isFocusable }
.zipWithNext { prev, curr ->
val jumpedUpward = curr.bounds.top < prev.bounds.top - jumpThresholdPx
if (!jumpedUpward) return@zipWithNext null

val prevTopDp = (prev.bounds.top / screenDensity).roundToInt()
val currTopDp = (curr.bounds.top / screenDensity).roundToInt()

issue(
node = curr,
message = "Focus jumps upward from ${prevTopDp}dp to ${currTopDp}dp. " +
"Screen readers will announce this element out of visual reading order.",
howToFix = "Reorder composables in the source so focus flows top-to-bottom, " +
"left-to-right, or apply Modifier.semantics { traversalIndex = n } " +
"to explicitly control the focus traversal sequence.",
)
}
.filterNotNull()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule

/**
* @param overlapThreshold Fraction of the text node's area that must intersect an image
* node before the pair is flagged (default 0.5 = 50%).
*/
class ImageWithTextOverlayRule(
private val overlapThreshold: Float = 0.5f,
) : BaseScanRule() {

override val ruleId = "image-text-overlay"
override val ruleName = "Image With Text Overlay"
override val severity = A11ySeverity.Warning
override val wcagReference = "WCAG 1.4.3 Contrast Minimum (Level AA)"

override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> {
val textNodes = nodes.filter { it.composableName.contains("Text", ignoreCase = true) }
val imageNodes = nodes.filter { it.composableName.contains("Image", ignoreCase = true) }

return textNodes.mapNotNull { textNode ->
val overlaps = imageNodes.any { imageNode ->
overlapRatio(textNode.bounds, imageNode.bounds) > overlapThreshold
}
if (!overlaps) return@mapNotNull null

issue(
node = textNode,
message = "Text rendered over image may fail contrast requirements on different images.",
howToFix = "Add a semi-transparent scrim or solid background behind the text " +
"(e.g. Modifier.background(Color.Black.copy(alpha = 0.5f))), or verify " +
"the image always provides sufficient contrast (4.5:1 normal, 3:1 large text).",
)
}
}

private fun overlapRatio(text: Rect, image: Rect): Float {
val intLeft = maxOf(text.left, image.left)
val intTop = maxOf(text.top, image.top)
val intRight = minOf(text.right, image.right)
val intBottom = minOf(text.bottom, image.bottom)

if (intRight <= intLeft || intBottom <= intTop) return 0f

val textArea = text.width * text.height
if (textArea == 0) return 0f

val intersectionArea = (intRight - intLeft) * (intBottom - intTop)
return intersectionArea.toFloat() / textArea
}
}

private typealias Rect = com.composea11yscanner.core.model.Rect
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule

class MissingContentDescriptionRule : BaseA11yRule() {

override val ruleId = "missing-content-description"
override val ruleName = "Missing Content Description"
override val severity = A11ySeverity.Error
override val wcagReference = "WCAG 1.1.1 Non-text Content (Level A)"

override fun check(node: A11yNode): A11yIssue? {
// Merged descendants are announced via their parent — skip them.
if (node.isMergedDescendant) return null

val isInteractive = node.isTouchTarget
// Covers Image, AsyncImage, SubcomposeAsyncImage, etc.
val isImage = node.composableName.contains("Image", ignoreCase = true)

if (!isInteractive && !isImage) return null
if (!node.contentDescription.isNullOrBlank()) return null

return issue(
node = node,
message = "Interactive element has no content description. " +
"Screen readers cannot announce this.",
howToFix = "Add a meaningful contentDescription via semantics: " +
"Modifier.semantics { contentDescription = \"Describe the action or content here\" }",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseScanRule
import kotlin.math.roundToInt

/**
* @param screenDensity Display density from DisplayMetrics.density.
* @param scaleFactor Font scale to simulate (default 1.3×).
*/
class TextScalingRule(
private val screenDensity: Float,
private val scaleFactor: Float = 1.3f,
) : BaseScanRule() {

override val ruleId = "text-scaling"
override val ruleName = "Text Scaling"
override val severity = A11ySeverity.Warning
override val wcagReference = "WCAG 1.4.4 Resize Text (Level AA)"

override fun evaluateAll(nodes: List<A11yNode>): List<A11yIssue> =
nodes
.filter { it.composableName.contains("Text", ignoreCase = true) }
.mapNotNull { textNode ->
val parent = findParent(textNode, nodes) ?: return@mapNotNull null

val scaledHeight = textNode.bounds.height * scaleFactor
val overflowPx = (textNode.bounds.top + scaledHeight) - parent.bounds.bottom
if (overflowPx <= 0f) return@mapNotNull null

val originalDp = (textNode.bounds.height / screenDensity).roundToInt()
val scaledDp = (scaledHeight / screenDensity).roundToInt()
val overflowDp = (overflowPx / screenDensity).roundToInt()

issue(
node = textNode,
message = "'${textNode.composableName}' may clip at ${scaleFactor}× 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 " +
"wrapContentHeight(), or wrap the content in a verticalScroll " +
"so text can reflow without clipping.",
)
}

// Tightest enclosing node at depth - 1 (smallest area that still fully contains the text node).
private fun findParent(node: A11yNode, allNodes: List<A11yNode>): A11yNode? =
allNodes
.filter { candidate ->
candidate.depth == node.depth - 1 &&
candidate.bounds.left <= node.bounds.left &&
candidate.bounds.top <= node.bounds.top &&
candidate.bounds.right >= node.bounds.right &&
candidate.bounds.bottom >= node.bounds.bottom
}
.minByOrNull { it.bounds.width * it.bounds.height }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.composea11yscanner.rules

import com.composea11yscanner.core.model.A11yIssue
import com.composea11yscanner.core.model.A11yNode
import com.composea11yscanner.core.model.A11ySeverity
import com.composea11yscanner.core.rule.BaseA11yRule

class TouchTargetRule(
private val minTouchTargetDp: Int = 48,
) : BaseA11yRule() {
override val ruleId = "touch-target-size"
override val ruleName = "Touch Target Size"
override val severity = A11ySeverity.Error
override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)"

override fun check(node: A11yNode): A11yIssue? {
if (!node.isTouchTarget) return null

val w = node.touchTargetSize.width
val h = node.touchTargetSize.height
if (w >= minTouchTargetDp && h >= minTouchTargetDp) return null

return issue(
node = node,
message = "Touch target is ${"%.0f".format(w)}x${"%.0f".format(h)}dp. " +
"Minimum required is ${minTouchTargetDp}x${minTouchTargetDp}dp.",
howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding so the " +
"composable reaches at least ${minTouchTargetDp}dp in both dimensions.",
)
}
}
Loading
Loading