Skip to content

a11y: VoiceOver support for geofence/region drag handles and discovery map pins (T010-T011)#18

Open
bruschill wants to merge 48 commits into
mainfrom
a11y/map-drag-handles
Open

a11y: VoiceOver support for geofence/region drag handles and discovery map pins (T010-T011)#18
bruschill wants to merge 48 commits into
mainfrom
a11y/map-drag-handles

Conversation

@bruschill

@bruschill bruschill commented Jul 21, 2026

Copy link
Copy Markdown
Owner

What changed?

Phase 1 (Blocker) accessibility fixes T010 and T011 from specs/016-accessibility-audit/tasks.md:

  • T010: GeofenceBoundsSelectorView.swift and RegionSelectorView.swift — the map-overlay selection rectangle's move handle (center) and four corner resize handles were operable only via DragGesture, with zero VoiceOver equivalent. Added, identically in both files:
    • The move handle gets four .accessibilityAction(named:) custom actions ("Move north", "Move south", "Move east", "Move west") that nudge the selection rect by a fixed 20pt step through the same clampedMove()/recompute() functions the drag gesture already calls.
    • Each corner handle gets .accessibilityAdjustableAction — increment grows the rectangle from that corner, decrement shrinks it — using the same per-corner min/max arithmetic as the existing resizeGesture, then the same normalizedClamped()/recompute().
    • Corner handles get directional VoiceOver labels ("Top leading corner handle", etc).
    • Both Maps are restricted to interactionModes: [.pan, .zoom] (no rotate/pitch) so the map stays north-up and the "north/south/east/west" labels stay accurate.
  • T011: DiscoveryMapView.swiftAnnotation("", coordinate: coord) had a literal empty title, so VoiceOver announced nothing for a discovered-node map pin. Added DiscoveredNodeEntity.displayName (long name → short name → last 4 hex digits of the node number, matching DiscoveryMapView's existing CircleText fallback in the same file) and used it as the annotation title.
  • Checked off T010 and T011 in 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?

  • Full xcodebuild build -scheme Meshtastic -destination 'generic/platform=iOS Simulator' after every commit — all BUILD SUCCEEDED.
  • Ran the meshtastic-swift-reviewer subagent against the full diff. It traced the nudgeCorner increment/decrement sign math against the existing resizeGesture switch 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 existing GeofenceCrossingStoreTests, WaypointGeofenceGeometryTests, WaypointApplyGeofenceTests, and DiscoveryModelTests suites (26/26 passed) and swiftlint 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 verification was screenshot-based rather than on-device VoiceOver, via a throwaway DEBUG-only launch-argument harness in a scratch commit that was fully reverted before every real commit — this repo has no UI test target, matching the existing project convention noted in the audit spec.

Manual Test Instructions

  • T010: open the Geofence Area selector (Node Detail → geofence editing) and, separately, the Offline Region "Choose Area" selector (Nodes → offline map download). With VoiceOver on:
    • Focus the center move handle (4-way arrow) → use the rotor/actions rotor to find "Move north" / "Move south" / "Move east" / "Move west" and trigger each → confirm the selection rectangle actually shifts on screen.
    • Focus each of the 4 corner resize handles → use the VoiceOver adjustable gesture (swipe up = increment, swipe down = decrement) → confirm the rectangle grows/shrinks from that specific corner.
    • Confirm the map only pans/zooms (no rotate gesture available) so the north/south/east/west action labels stay accurate.
  • T011: Settings → Discovery → Discovery Map, with at least one discovered node plotted. Focus a node pin — should announce the node's actual name (long name, or short name, or last-4-hex-digit fallback), not silence.

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 bounds selector
T010 — Geofence Area selector showing the move handle (center, 4-way arrow) and all four corner resize handles over the map.

T010 Offline region selector
T010 — Offline map "Choose Area" region selector with the same move + corner handle set, fully framed over San Francisco.

T011 Discovery map annotation titles
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

  • My code adheres to the project's coding and style guidelines.
  • I have conducted a self-review of my code.
  • I have commented my code, particularly in complex areas.
  • I have verified whether these changes require updates to the in-app documentation under docs/user/ or docs/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 no docs/user/ or docs/developer/ page needs a content change. Apply the skip-docs-check label (same call as PR a11y: Dynamic Type support for Compact Widget metric tiles meshtastic/Meshtastic-Apple#2123).
  • I have tested the change to ensure that it works as intended. Full simulator builds after every commit, subagent code review with build + existing test suite run, and screenshot verification of all three affected screens.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

📄 Docs staleness warning

This PR modifies user-facing Swift source files but does not update any page under docs/user/ or docs/developer/.

Changed source files:

Meshtastic/Model/DeviceMetadataEntity.swift
Meshtastic/Model/DiscoveredNodeEntity.swift
Meshtastic/Views/Connect/Connect.swift
Meshtastic/Views/Helpers/Weather/IndoorAirQuality.swift
Meshtastic/Views/Messages/ChannelMessageRow.swift
Meshtastic/Views/Messages/MessageText.swift
Meshtastic/Views/Messages/TextMessageField/TextMessageField.swift
Meshtastic/Views/Messages/UserList.swift
Meshtastic/Views/Messages/UserMessageRow.swift
Meshtastic/Views/Nodes/Helpers/Map/GeofenceBoundsSelectorView.swift
Meshtastic/Views/Nodes/Helpers/Map/Offline/RegionSelectorView.swift
Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift
Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
Meshtastic/Views/Settings/AppLog.swift
Meshtastic/Views/Settings/Discovery/DiscoveryMapView.swift

What to check:

Changed area Likely doc page
Views/Messages/ docs/user/messages.md
Views/Nodes/ docs/user/nodes.md
Views/Map/ docs/user/map.md
Views/Settings/Bluetooth/ docs/user/bluetooth.md
Views/Settings/Discovery/ docs/user/discovery.md
Views/Settings/MQTT/ docs/user/mqtt.md
Views/Settings/TAK/ docs/user/tak.md
Views/Settings/Firmware/ docs/user/firmware.md
Views/Settings/ (telemetry/sensor) docs/user/telemetry.md
Views/Settings/ (general) docs/user/settings.md
Meshtastic Watch App/ docs/user/watch.md
Model/ docs/developer/swiftdata.md or docs/developer/architecture.md
Accessory/Transports/ docs/developer/transport.md

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this warning.

After updating docs/, re-run bash scripts/build-docs.sh --output Meshtastic/Resources/docs locally and commit the regenerated HTML bundle.

RCGV1 and others added 29 commits July 22, 2026 13:18
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.
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.
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
bruschill force-pushed the a11y/map-drag-handles branch from e3b3739 to 4748430 Compare July 23, 2026 03:55
bruschill and others added 17 commits July 22, 2026 22:55
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.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants