a11y: VoiceOver support for geofence/region drag handles and discovery map pins (T010-T011)#18
Open
bruschill wants to merge 48 commits into
Open
a11y: VoiceOver support for geofence/region drag handles and discovery map pins (T010-T011)#18bruschill wants to merge 48 commits into
bruschill wants to merge 48 commits into
Conversation
📄 Docs staleness warningThis PR modifies user-facing Swift source files but does not update any page under Changed source files: What to check:
If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the After updating |
Correct mistranslated technical terms and anglicisms in Localizable.xcstrings, including: - "Range Test": "teste de intervalo" -> "teste de alcance" - "Retrieving nodes": "Retrievendo nós" -> "Obtendo nós" - "Retry": "Recarregar" -> "Tentar novamente" - "Ringtone" and variants: "Som" -> "Toque" - other pt-BR strings reviewed for naturalness
Deep a11y audit fan-out (12 parallel agents + cross-validation) over Meshtastic/Views, Widgets/, and Meshtastic Watch App/Views found 20 confirmed finding clusters (5 Blocker, 11 High, 4 Medium) across ~60 locations. tasks.md is the persistent, resumable checklist — one checkbox per finding, grouped by severity, with file:line and the proposed fix pattern for each.
Three icon-only buttons (arrow.up.circle.fill send, x.circle.fill cancel-reply, face.smiling emoji picker) had no accessibilityLabel, so VoiceOver announced them as a bare "Button" with no indication of what they do. Fixed in both TextMessageField's legacy body (iOS <18/ macOS <15) and FormattingComposeArea (iOS 18+/macOS 15+) — six call sites total, one localized label per action, following the existing String(localized:) + comment convention from ChannelList.swift and MessageText.swift. Closes T002, T015, T016 in specs/016-accessibility-audit/tasks.md.
Series and column rows toggled visibility on .onTapGesture with no accessibility trait, so VoiceOver double-tap did nothing. Group each row into one element, label it by name, report Visible/Hidden as the value, mark it a button, and wire accessibilityAction to the same toggle the tap gesture already calls.
The Wi-Fi provisioning discovery row used .onTapGesture with no trait, so VoiceOver double-tap couldn't start provisioning. Group the row, label it with the device name, mark it a button, and wire accessibilityAction to the same onSelect() the tap gesture calls.
Tapping the IAQ badge toggled the legend popover via .onTapGesture with no accessibility trait, so VoiceOver double-tap couldn't open it. Add contentShape for a reliable hit target, group the badge into one element, label it by IAQ description, report the popover's visible/hidden state, mark it a button, and wire accessibilityAction to the same toggle.
Each streamed log row opened its detail sheet via .onTapGesture with no accessibility trait, so VoiceOver double-tap couldn't select an entry. Group the row, label it with the log message, report the timestamp as the value, mark it a button, and wire accessibilityAction to the same selectedLog assignment the tap gesture uses.
First heard and Last heard rows already grouped their children with .accessibilityElement(children: .combine) but had no interactive trait, so VoiceOver double-tap on the row silently did nothing even though tapping toggles the relative/absolute date format. Add .isButton and wire accessibilityAction to the same dateFormatRelative.toggle() the tap gesture calls.
Metrics column detail, Nymea connect row, indoor air quality legend, app log stream rows, and node detail heard-time rows all now have accessibility traits on their tap-gesture rows.
Reviewer flagged that Visible/Hidden read as describing the air quality reading itself, not the legend popover's open state. Say 'Legend shown'/'Legend hidden' instead so VoiceOver users know what the value actually refers to.
Combining the row into one accessibility element for the tap-gesture trait fix replaced SignalStrengthIndicator's own accessibilityValue instead of appending to it, so VoiceOver users lost the weak/normal/ strong signal announcement they could reach before this branch (as its own swipe stop). Fold it into the row's accessibilityValue so the information survives the combine.
…T016 closed by this PR
Deep a11y audit fan-out (12 parallel agents + cross-validation) over Meshtastic/Views, Widgets/, and Meshtastic Watch App/Views found 20 confirmed finding clusters (5 Blocker, 11 High, 4 Medium) across ~60 locations. tasks.md is the persistent, resumable checklist — one checkbox per finding, grouped by severity, with file:line and the proposed fix pattern for each.
Three icon-only buttons (arrow.up.circle.fill send, x.circle.fill cancel-reply, face.smiling emoji picker) had no accessibilityLabel, so VoiceOver announced them as a bare "Button" with no indication of what they do. Fixed in both TextMessageField's legacy body (iOS <18/ macOS <15) and FormattingComposeArea (iOS 18+/macOS 15+) — six call sites total, one localized label per action, following the existing String(localized:) + comment convention from ChannelList.swift and MessageText.swift. Closes T002, T015, T016 in specs/016-accessibility-audit/tasks.md.
UserMessageRow and ChannelMessageRow each built their combined accessibilityLabel by hand-checking only encryption and signature flags, so it silently dropped store-and-forward, detection sensor, and translated-text badges that MessageText draws and labels individually. The two lists could also drift from each other and from MessageText's own badge logic since each copy hand-listed its own conditions. Added MessageEntity.activeStatusBadges(destination:isCurrentUser:), a single source of truth for which status badges apply to a message and what each one's VoiceOver label is. MessageText's cornerBadges and sensor/translate overlays now read from it instead of duplicating the boolean checks, and both message rows build their combined label from the same list. Moved the destination-based sensor-badge gate out of a private MessageText extension into MessageDestination so it's shared too.
The contact list row had no accessibility support: the unread dot was visible to VoiceOver even when hidden, the encryption lock/key icons and favorite star had no labels, and the row had no combined element so VoiceOver read each subview separately. Hid the unread dot when there's nothing unread and gave it an unread-count label, matching the pattern already used for the channel list's unread dot. Added labels to the three lock/key states (encrypted, key mismatch, not encrypted) and the favorite star. Combined the whole row into one accessibility element so VoiceOver reads it as a single coherent stop.
ChannelMessageRow had its own local isDetectionSensorMessage check (message.portNum == detectionSensorApp) that duplicated, under a same-sounding name, the destination-gated check T003 just centralized in MessageEntity.isDetectionSensorMessage(destination:). Harmless today since this row is always a channel destination, but it's exactly the kind of hand-rolled duplicate that fix was meant to eliminate. Point it at the shared function instead.
… silently drop a badge again MessageEntityExtension's isEncryptedMessage, isStoreForwardMessage, isDetectionSensorMessage, isShowingTranslatedText, StatusBadge.label, and activeStatusBadges are the exact logic issue #16 T003 fixed (the combined VoiceOver label was dropping badge state the per-badge overlays showed) and had zero unit coverage. Pins the || logic for the encrypted badge (including the one combination that must stay false: pkiEncrypted + isCurrentUser + !realACK), the portNum/destination gating for storeForward and detectionSensor, and activeStatusBadges' build order.
…T016 closed by this PR
Deep a11y audit fan-out (12 parallel agents + cross-validation) over Meshtastic/Views, Widgets/, and Meshtastic Watch App/Views found 20 confirmed finding clusters (5 Blocker, 11 High, 4 Medium) across ~60 locations. tasks.md is the persistent, resumable checklist — one checkbox per finding, grouped by severity, with file:line and the proposed fix pattern for each.
Three icon-only buttons (arrow.up.circle.fill send, x.circle.fill cancel-reply, face.smiling emoji picker) had no accessibilityLabel, so VoiceOver announced them as a bare "Button" with no indication of what they do. Fixed in both TextMessageField's legacy body (iOS <18/ macOS <15) and FormattingComposeArea (iOS 18+/macOS 15+) — six call sites total, one localized label per action, following the existing String(localized:) + comment convention from ChannelList.swift and MessageText.swift. Closes T002, T015, T016 in specs/016-accessibility-audit/tasks.md.
GeofenceBoundsSelectorView and RegionSelectorView both draw a selection rectangle over a map with a center move handle and four corner resize handles, operated only by DragGesture. VoiceOver users had no way to touch and drag these, so the geofence/region area was unreachable without sight. Added accessibility to both, same pattern in each file: - Move handle gets accessibilityActions (Move north/south/east/west) that nudge selectionRect.origin by a fixed 20pt step through the same clampedMove()/recompute() path the drag gesture already uses. - Each corner handle gets accessibilityAdjustableAction: increment grows the rect from that corner (moves it away from center), decrement shrinks it back, using the same per-corner min/max math as resizeGesture and the same normalizedClamped()/recompute() path. - Corner handles get a directional label (top leading/trailing, bottom leading/trailing) via a new cornerLabel() helper so VoiceOver announces which corner is focused. Verified with a full simulator build (BUILD SUCCEEDED). Closes T010 in specs/016-accessibility-audit/tasks.md.
DiscoveryMapView annotated each discovered node with Annotation("", ...),
a literally empty title, so VoiceOver announced nothing for the pin.
Added DiscoveredNodeEntity.displayName (long name, falling back to
short name, falling back to the last 4 hex digits of the node number,
matching the existing DiscoveredBeaconEntity.displayName pattern) and
used it as the Annotation title instead of the empty string.
Verified with a full simulator build (BUILD SUCCEEDED).
Closes T011 in specs/016-accessibility-audit/tasks.md.
Both fixes landed and were verified with a full simulator build plus screenshots of the geofence selector, offline region selector, and discovery map. Leaves every other checkbox in the tracker untouched.
Follow-up from meshtastic-swift-reviewer on T010/T011: - GeofenceBoundsSelectorView and RegionSelectorView both label the move handle's VoiceOver actions "Move north/south/east/west", but the map underneath allowed free rotate/pitch. If a user rotated the map, north would stop meaning screen-up and the action labels would lie about direction. Restrict both Maps to interactionModes: [.pan, .zoom] so the map stays north-up and the labels stay accurate. Neither selector needs rotate/pitch for framing a rectangular area. - Added a doc comment on DiscoveredNodeEntity.displayName explaining why its hex fallback (last 4 hex digits) intentionally differs from DiscoveredBeaconEntity.displayName's !%08x format: it matches DiscoveryMapView's existing CircleText fallback in the same file, so a pin's VoiceOver title and its visible abbreviation stay consistent. Verified with a full simulator build (BUILD SUCCEEDED).
bruschill
force-pushed
the
a11y/map-drag-handles
branch
from
July 23, 2026 03:55
e3b3739 to
4748430
Compare
Deep a11y audit fan-out (12 parallel agents + cross-validation) over Meshtastic/Views, Widgets/, and Meshtastic Watch App/Views found 20 confirmed finding clusters (5 Blocker, 11 High, 4 Medium) across ~60 locations. tasks.md is the persistent, resumable checklist — one checkbox per finding, grouped by severity, with file:line and the proposed fix pattern for each.
Three icon-only buttons (arrow.up.circle.fill send, x.circle.fill cancel-reply, face.smiling emoji picker) had no accessibilityLabel, so VoiceOver announced them as a bare "Button" with no indication of what they do. Fixed in both TextMessageField's legacy body (iOS <18/ macOS <15) and FormattingComposeArea (iOS 18+/macOS 15+) — six call sites total, one localized label per action, following the existing String(localized:) + comment convention from ChannelList.swift and MessageText.swift. Closes T002, T015, T016 in specs/016-accessibility-audit/tasks.md.
WidgetsLiveActivity.swift had zero accessibility calls despite being a Lock Screen / Dynamic Island Live Activity where the icon+number rows (person.2.fill glyph next to a bare fraction) and StatRow pairs read as meaningless separate elements, or nothing at all, under VoiceOver. Grouped each icon+count/fraction pair with .accessibilityElement(children: .ignore) and a combined, localized .accessibilityLabel: - Dynamic Island expanded trailing region (nodesOnline/totalNodes) - Dynamic Island compact-leading region (nodesOnline) - Dynamic Island minimal presentation (nodesOnline) - LiveActivityView header row (nodesOnline/totalNodes) Grouped StatRow (label + value) into one combined "label: value" element instead of two disconnected VoiceOver stops. TimerView (the countdown) now announces "Update in" as its label with the live-updating countdown text carried as accessibilityValue, so VoiceOver gets both the semantic purpose and the current countdown state instead of silently skipping the timer glyph and digits.
…closed by this PR
fix(i18n): correct inaccurate pt-BR translations
…ta-lifecycle fix(swiftdata): preserve device metadata entity during updates
…signed Revert "fix(i18n): correct inaccurate pt-BR translations" (meshtastic#2164)
…missing xcstrings for PR meshtastic#2171 Annotation(_:coordinate:content:)'s title argument drives MapKit-internal behavior (clustering/collision text) -- it is NOT surfaced as the hosted SwiftUI content's accessibility label. T011 set node.displayName as the title, but CircleText has no accessibility modifiers of its own and Image(systemName:)'s default label is just the SF Symbol's generic description, so VoiceOver still announced the short name text + a generic icon description instead of the node's display name. Add an explicit .accessibilityElement(children: .combine) + .accessibilityLabel(node.displayName) directly on the hosted content, which is what actually drives the VoiceOver announcement. Also adds the 16 Localizable.xcstrings entries this branch's new String(localized:) calls (drag-handle labels, hints, and custom nudge actions) were missing.
…c#2172 Adds the 7 missing keys for this branch's new String(localized:) calls (node-count announcements, StatRow label:value, countdown timer label, and the shared TextMessageField labels), matching the pattern every earlier a11y commit on this branch already established.
a11y: VoiceOver support for Live Activity / Dynamic Island (T001)
# Conflicts: # specs/016-accessibility-audit/tasks.md
a11y: fix dropped message badges in combined VoiceOver label, label DM contact rows (T003-T004)
# Conflicts: # specs/016-accessibility-audit/tasks.md
…aits a11y: add VoiceOver button trait + action to onTapGesture-only rows (T005-T009)
# Conflicts: # specs/016-accessibility-audit/tasks.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed?
Phase 1 (Blocker) accessibility fixes T010 and T011 from
specs/016-accessibility-audit/tasks.md:GeofenceBoundsSelectorView.swiftandRegionSelectorView.swift— the map-overlay selection rectangle's move handle (center) and four corner resize handles were operable only viaDragGesture, with zero VoiceOver equivalent. Added, identically in both files:.accessibilityAction(named:)custom actions ("Move north", "Move south", "Move east", "Move west") that nudge the selection rect by a fixed 20pt step through the sameclampedMove()/recompute()functions the drag gesture already calls..accessibilityAdjustableAction— increment grows the rectangle from that corner, decrement shrinks it — using the same per-corner min/max arithmetic as the existingresizeGesture, then the samenormalizedClamped()/recompute().Maps are restricted tointeractionModes: [.pan, .zoom](no rotate/pitch) so the map stays north-up and the "north/south/east/west" labels stay accurate.DiscoveryMapView.swift—Annotation("", coordinate: coord)had a literal empty title, so VoiceOver announced nothing for a discovered-node map pin. AddedDiscoveredNodeEntity.displayName(long name → short name → last 4 hex digits of the node number, matchingDiscoveryMapView's existingCircleTextfallback in the same file) and used it as the annotation title.specs/016-accessibility-audit/tasks.md(no other checkboxes touched).Why did it change?
Both issues make VoiceOver users unable to perceive or operate core map-editing features: the geofence/offline-region area selectors had literally no non-visual way to move or resize the selection box, and discovered nodes on the Discovery map announced nothing at all when focused.
How is this tested?
xcodebuild build -scheme Meshtastic -destination 'generic/platform=iOS Simulator'after every commit — allBUILD SUCCEEDED.meshtastic-swift-reviewersubagent against the full diff. It traced thenudgeCornerincrement/decrement sign math against the existingresizeGestureswitch for all four corners and confirmed no sign error, confirmed the four corner accessibility elements don't collapse into one ambiguous element, and found no concurrency/Sendable/SwiftData issues. It ran a full build plus the existingGeofenceCrossingStoreTests,WaypointGeofenceGeometryTests,WaypointApplyGeofenceTests, andDiscoveryModelTestssuites (26/26 passed) andswiftlint lint(clean). Verdict: approve-with-nits; the two substantive nits (map rotation making the compass labels inaccurate, missing doc comment on the hex-fallback divergence) were fixed in a follow-up commit and re-verified with another full build.Manual Test Instructions
This PR's own "How is this tested?" notes verification was screenshot-based rather than a live VoiceOver pass — the above closes that gap.
Screenshots/Videos
T010 — Geofence Area selector showing the move handle (center, 4-way arrow) and all four corner resize handles over the map.
T010 — Offline map "Choose Area" region selector with the same move + corner handle set, fully framed over San Francisco.
T011 — Discovery map showing two node pins: one labeled "Ridge Mesh 1" (long name) and one labeled "ee12" (hex-id fallback) — confirms the annotation title is no longer an empty string.
Checklist
docs/user/ordocs/developer/, and updated accordingly. No doc update needed — these are VoiceOver-only accessibility additions to existing controls (no new UI, strings, or user-facing flow a sighted user would see), so nodocs/user/ordocs/developer/page needs a content change. Apply theskip-docs-checklabel (same call as PR a11y: Dynamic Type support for Compact Widget metric tiles meshtastic/Meshtastic-Apple#2123).