diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3bd97c5..5d7c0a1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -68,3 +68,22 @@ Promote to release → 1.4.2-beta.1 → 1.4.2 (versionCode +1) - Minimum tap target: 44×44dp - Never use `fallbackToDestructiveMigration` in the Room database config - **Never use en dashes (–) or em dashes (—) in user-facing text.** They read as robotic. Use a period, colon, or reword the sentence instead. Hyphens in genuine compound words ("in-app", "4-digit", "built-in", "30-day") are fine. + +## Accessibility Rules (enforced by `a11y_check.py` in CI) + +Every `.clickable {}` or `.combinedClickable {}` modifier **must** carry a matching `.semantics { role = Role. }` in the same modifier chain. Use the role that best describes the element: + +| Role | Use for | +|---|---| +| `Role.Button` | Navigation, generic action, expand/collapse, dialog dismiss | +| `Role.RadioButton` | Mutually exclusive single-select (theme pickers, icon pickers, format selectors) | +| `Role.Checkbox` | Toggle with two named states where the element acts as a row wrapping a Checkbox | +| `Role.Switch` | Toggle with two named states; pair with `stateDescription` to announce current state | + +Additional rules: +- Place `.semantics { role = }` **before** `.clickable {}` in the chain when the clickable lambda is longer than a few lines, so the CI window check can find it. +- When the parent Row/Box handles the click, set the inner `Checkbox` / `RadioButton` to `onClick = null` to prevent double-focus. +- `clearAndSetSemantics { }` must also include `role = Role.` — it replaces all child semantics, so the role must be re-declared there. +- Status text that appears or changes in response to user action needs `Modifier.semantics { liveRegion = LiveRegionMode.Assertive }` (errors) or `LiveRegionMode.Polite` (non-urgent feedback). +- Icon-only interactive controls (FABs, icon-only buttons outside of `IconButton`) need `Modifier.semantics { contentDescription = "" }` on the container itself. +- Run `python3 a11y_check.py` locally before pushing to confirm no new violations. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d4756b..2f79406 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Accessibility role check + run: python3 a11y_check.py --fails-only + - name: Set up JDK 17 uses: actions/setup-java@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index d5beb4e..bba0b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,16 @@ Rules: - Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward --- +## [0.22.4-beta.1] - 2026-06-02 + +### Fixed +- Additional accessibility role fixes missed in the initial audit: archive-dialog "Don't show this again" row (`Role.Checkbox`), alarm-permission list item in reminder settings (`Role.Button`), export-scope and export-format radio rows (`Role.RadioButton` with `RadioButton(onClick = null)`), theme-mode selector (`Role.RadioButton`), palette picker (`Role.RadioButton`), app-icon picker (`Role.RadioButton`), calendar day cells (`role = Role.Button` inside `clearAndSetSemantics`), and speed-dial scrim dismiss overlay (`Role.Button` + contentDescription). + +### Added +- `a11y_check.py`: Python CI script that scans all Compose source files and fails if any `.clickable` or `.combinedClickable` modifier chain is missing a `.semantics { role = Role.* }` declaration. Runs as an early fast-fail step on every PR. +- CI workflow (`build.yml`) now runs the accessibility role check before the build step so violations are caught before a full Gradle build. +- Accessibility coding rules added to `.claude/CLAUDE.md` so future AI-assisted changes follow the same patterns automatically. + ## [0.22.3-beta.1] - 2026-06-02 ### Fixed diff --git a/a11y_check.py b/a11y_check.py new file mode 100644 index 0000000..f6d8098 --- /dev/null +++ b/a11y_check.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +a11y_check.py — Accessibility role checker for GoFlo. + +Scans Compose source files for .clickable / .combinedClickable modifier chains +that lack a companion .semantics { role = Role.* } declaration. + +Every custom clickable element must declare an explicit role so TalkBack, +Switch Access, and keyboard navigation can discover and activate it. +See LESSONS.md (Android / Compose) for the correct pattern. + +Usage: + python3 a11y_check.py # check all source files + python3 a11y_check.py --fails-only # same (alias, kept for parity) + python3 a11y_check.py path/to/file.kt # check a single file + +Exit: 0 = no violations | 1 = one or more violations found +""" + +import os +import re +import sys +from pathlib import Path + +SEARCH_ROOTS = ["app/src/main/java"] + +# Lines either side of a .clickable call to search for a role declaration. +# Modifier chains are typically 2-10 lines; 15 gives comfortable headroom. +ROLE_WINDOW = 15 + +# Matches genuine method calls, not mentions inside comments or strings. +CLICKABLE_RE = re.compile(r"\.(clickable|combinedClickable)\s*[{(]") +ROLE_RE = re.compile(r"\brole\s*=\s*Role\.") + + +def find_kt_files(roots: list[str]) -> list[Path]: + files: list[Path] = [] + for root in roots: + for dirpath, _, names in os.walk(root): + for name in names: + if name.endswith(".kt"): + files.append(Path(dirpath) / name) + return sorted(files) + + +def check_file(path: Path) -> list[tuple[int, str]]: + """Return (1-based line number, stripped text) for each violation.""" + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return [] + + violations: list[tuple[int, str]] = [] + for i, line in enumerate(lines): + stripped = line.strip() + + # Skip comment lines + if stripped.startswith("//") or stripped.startswith("*"): + continue + + if not CLICKABLE_RE.search(line): + continue + + # Check surrounding window for a role declaration + lo = max(0, i - ROLE_WINDOW) + hi = min(len(lines), i + ROLE_WINDOW + 1) + window = "\n".join(lines[lo:hi]) + + if not ROLE_RE.search(window): + violations.append((i + 1, stripped)) + + return violations + + +def main() -> int: + args = sys.argv[1:] + file_args = [a for a in args if not a.startswith("--")] + + files = [Path(f) for f in file_args] if file_args else find_kt_files(SEARCH_ROOTS) + + checked = 0 + violations = 0 + + for path in files: + hits = check_file(path) + checked += 1 + if hits: + violations += len(hits) + print(f"\n{path}") + for lineno, text in hits: + print(f" line {lineno:4d}: {text}") + + print(f"\n{'═'*70}") + print(f" {checked} file(s) checked") + if violations: + print(f" {violations} violation(s) ✗") + print(f"\n Each .clickable / .combinedClickable must carry") + print(f" .semantics {{ role = Role. }} in the same modifier chain.") + print(f" See LESSONS.md (Android / Compose) for the correct pattern.") + else: + print(f" All clean ✓") + print(f"{'═'*70}") + + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0d5c97d..4d2bbde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 60 - versionName = "0.22.3-beta.1" + versionCode = 61 + versionName = "0.22.4-beta.1" } signingConfigs { diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/CalendarGrid.kt b/app/src/main/java/com/mapgie/goflo/ui/components/CalendarGrid.kt index 037cf6a..944c5dd 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/CalendarGrid.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/CalendarGrid.kt @@ -36,8 +36,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import java.time.LocalDate @@ -296,7 +298,7 @@ private fun DayCell( ) // clearAndSetSemantics replaces all child semantics so TalkBack reads // only this description, not the raw day-number Text inside the Box. - .clearAndSetSemantics { contentDescription = cellDescription } + .clearAndSetSemantics { contentDescription = cellDescription; role = Role.Button } .padding(2.dp), contentAlignment = Alignment.Center ) { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt index 3ef3384..9623548 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt @@ -215,11 +215,13 @@ fun ManageCategoriesScreen( ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { doNotShowAgain = !doNotShowAgain } + modifier = Modifier + .clickable { doNotShowAgain = !doNotShowAgain } + .semantics { role = Role.Checkbox } ) { Checkbox( checked = doNotShowAgain, - onCheckedChange = { doNotShowAgain = it } + onCheckedChange = null ) Spacer(Modifier.width(4.dp)) Text("Don't show this again", style = MaterialTheme.typography.bodySmall) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index e76dbf5..26a68d7 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -55,7 +55,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.BuildConfig @@ -274,6 +276,7 @@ fun HomeScreen( indication = null, onClick = { showLogMenu = false; logMenuTargetDate = null } ) + .semantics { role = Role.Button; contentDescription = "Close menu" } ) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt index eb41cd0..ead1d81 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/settings/SettingsScreen.kt @@ -1009,14 +1009,16 @@ private fun RemindersSubScreen( headlineColor = MaterialTheme.colorScheme.onErrorContainer, supportingColor = MaterialTheme.colorScheme.onErrorContainer, ), - modifier = Modifier.clickable { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - context.startActivity( - Intent(AndroidSettings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) + modifier = Modifier + .clickable { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.startActivity( + Intent(AndroidSettings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } } - } + .semantics { role = Role.Button } ) HorizontalDivider() } @@ -1500,10 +1502,11 @@ private fun ExportDataSubScreen( modifier = Modifier .fillMaxWidth() .clickable { fullBackup = false } + .semantics { role = Role.RadioButton } .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - RadioButton(selected = !fullBackup, onClick = { fullBackup = false }) + RadioButton(selected = !fullBackup, onClick = null) Spacer(Modifier.width(4.dp)) Column { Text("Data only", style = MaterialTheme.typography.bodyMedium) @@ -1518,10 +1521,11 @@ private fun ExportDataSubScreen( modifier = Modifier .fillMaxWidth() .clickable { fullBackup = true } + .semantics { role = Role.RadioButton } .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - RadioButton(selected = fullBackup, onClick = { fullBackup = true }) + RadioButton(selected = fullBackup, onClick = null) Spacer(Modifier.width(4.dp)) Column { Text("Full backup", style = MaterialTheme.typography.bodyMedium) @@ -1624,10 +1628,11 @@ private fun ExportDataSubScreen( modifier = Modifier .fillMaxWidth() .clickable { format = f } + .semantics { role = Role.RadioButton } .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - RadioButton(selected = format == f, onClick = { format = f }) + RadioButton(selected = format == f, onClick = null) Spacer(Modifier.width(4.dp)) Column { Text(f.name, style = MaterialTheme.typography.bodyMedium) @@ -1839,6 +1844,7 @@ private fun CompactThemePicker( if (selected) MaterialTheme.colorScheme.primary else Color.Transparent ) + .semantics { role = Role.RadioButton } .clickable { when (mode) { ThemeMode.SYSTEM -> { @@ -1945,6 +1951,7 @@ private fun PaletteOption(palette: StandardPalette, selected: Boolean, onClick: Column( modifier = Modifier .clickable(onClick = onClick) + .semantics { role = Role.RadioButton } .padding(horizontal = 12.dp, vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) @@ -2105,6 +2112,7 @@ private fun IconChoiceCell( Column( modifier = Modifier .clickable(onClick = onClick) + .semantics { role = Role.RadioButton } .padding(horizontal = 8.dp, vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)