Skip to content

Speak the runway facts that only color was carrying - #19

Merged
RISCfuture merged 1 commit into
mainfrom
told/a11y-runway-rows
Sep 3, 2026
Merged

Speak the runway facts that only color was carrying#19
RISCfuture merged 1 commit into
mainfrom
told/a11y-runway-rows

Conversation

@RISCfuture

@RISCfuture RISCfuture commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

RunwayRow had no accessibility modifiers at all, so VoiceOver read it as one run-on concatenation of runway name, distances, "(turf)" and wind components. Worse, three safety-relevant facts were conveyed by color alone and were unreachable:

  • a distance a NOTAM has shortened, tinted Color.ui.warning (RunwayDistances.swift:56)
  • a wind component past the tailwind or crosswind limit, turned red (WindComponents.swift:70,82,93)
  • a takeoff distance past the runway available, turned red (InterpolationView.swift:189-214)

accessibilityCustomContent adds these as labelled items without disturbing the label each view already synthesizes.

What is attached

RunwayRow — operation-sensitive, matching what RunwayDistances draws:

Importance Label Value
.high Contamination Water/slush n″, Slush/wet snow n″, Dry snow, Compact snow, Wet runway, Runway condition code n
.high NOTAM "Shortened n ft at the threshold end" / "…at the departure end"
.high Displaced threshold the distance
.high Tailwind "Exceeds n kt limit"
.high Crosswind "Exceeds n kt limit"
.default Takeoff run available TORA, only when it differs from TODA
.default Takeoff distance available TODA
.default Landing distance available LDA
.default Surface Paved / Grooved / Porous friction course / Turf
.default Slope "0.8% upslope" / "0.8% downslope" / "Level"

TakeoffDistanceView.high "Runway": "Available takeoff distance insufficient" / "…sufficient", the wording already shipping in the widget (RunwayGridItem.swift:16,20). .default "Takeoff distance available".

LandingDistanceView.default "Landing distance available" only. There is deliberately no sufficiency verdict: the view passes InterpolationView no maximum: and LandingPerformanceViewModel has only availableLandingRun, so no exceedance exists to report to either audience.

A shortening is described as the reduction it is, never as a closure — NOTAM cannot express a closure, and a runway closed outright reaches the model only as a shortening the length of the runway.

The row has to combine its children

Custom content propagates to every accessibility leaf it covers, not to the row as a whole. Uncombined, all of it hung on each of the six leaves the row produces. NavigationLink hid this on the takeoff and landing forms by combining anyway, but RunwayPicker — a List row whose button trait comes from a modifier — combines nothing, so VoiceOver stopped six times per runway and repeated every .high item at each stop. That is the screen where runways are actually compared.

.accessibilityElement(children: .combine) is applied inside RunwayRow, before the custom content, so the content lands once on the combined element. Measured on the real view, hosted in a real window, reading accessibilityCustomContent off each element via responds(to:) + value(forKey:):

BEFORE                                    AFTER
6 leaf elements, 5 items each             1 element, 5 items
  "30"                                      "30, 2,621 ft, tailwind, 19, left crosswind, 21"
  "2,621 ft"                                id=runwayRow-30  isButton=true
  "tailwind"                                · Tailwind = "Exceeds 10 kn limit"  [high]
  "19"                                      · Crosswind = "Exceeds 16 kn limit" [high]
  "left crosswind"                          · Takeoff distance available = "2,621 ft"
  "21"                                      · Surface = "Paved"
  (each carrying all 5 items)               · Slope = "Level"

Five items rather than ten because this runway has no NOTAM, no displaced threshold, and TORA == TODA — the guards suppress the rest, as intended.

Also

  • Deleted the dead notam: NOTAM? property on RunwayRow. No caller passed it and nothing read it; the row now reads the Runway.notam relationship that RunwayDistances already reaches through.
  • Added FormatStyle.slope (percent, one fraction digit) alongside the other format styles in formatters.swift.
  • #Preview blocks now cover takeoff and a NOTAMed, contaminated landing row.

The UI suite

No .accessibilityLabel anywhere, and .accessibilityElement only on RunwayRow — the two distance views are untouched in that respect, since the suite parses element.label for numeric values on takeoffDistanceValue / landingDistanceValue.

The combine preserves what the suite queries: runwayRow-30 still resolves, isButton survives, and the label stays non-empty (verified in the probe output above).

  • swiftlint --strict on the changed files: clean (the 5 repo-wide violations are pre-existing legacy_swiftui_aspect_ratio findings in untouched files)
  • swift-format lint --strict on the changed files: clean
  • xcodebuild build: succeeded
  • testLandingResultsValueCorrectness (parses landingDistanceValue, asserts 1769 ±40) and testRunwayPickerShowsCorrectWindComponents (drives the runway picker, reads runwayRowLabel("28R")): both passed

🤖 Generated with Claude Code

https://claude.ai/code/session_01EdB27epdLAZ1x23zkSJ8LT

@RISCfuture
RISCfuture force-pushed the told/a11y-runway-rows branch from ca86de4 to 5e8a49f Compare September 3, 2026 01:22
A runway row had no accessibility modifiers at all, so VoiceOver read it as one
run-on concatenation and heard none of the three warnings the row draws in
color: a distance a NOTAM has shortened, tinted warning; a wind component past
the tailwind or crosswind limit, turned red; and a takeoff distance past the
runway available, turned red. Color was the only signal for each, so a VoiceOver
user was told a runway was usable while the screen was saying it was not.

accessibilityCustomContent adds the facts as labelled items without disturbing
the label the row already synthesizes. That matters: the UI suite queries
staticTexts by identifier and parses the numbers out of element.label, so
substituting a label would have broken it.

The row also has to combine its children. Custom content propagates to every
leaf it covers rather than to the row as a whole, so an uncombined row hung all
of it on each of the six leaves the name, distance and wind components produce.
A NavigationLink hid that on the takeoff and landing forms by combining anyway,
but RunwayPicker — a List row that gets its button trait from a modifier —
combines nothing, and that is the screen where runways are compared: VoiceOver
stopped six times per runway and repeated every high-importance item at each
stop. The identifier and the button trait are applied outside the row and still
land on the combined element.

Contamination, a shortening, a displaced threshold and each exceeded wind limit
are .high, so VoiceOver speaks them without being asked; the declared distances,
the surface and the slope are .default and wait in the rotor. A shortening is
described as the reduction it is rather than as a closure, because a closure is
not something NOTAM can express — a runway closed outright reaches the model
only as a shortening the length of the runway.

The row reads runway.notam, the relationship RunwayDistances already reaches
through, and the unused notam property it declared instead is gone: no caller
passed it and nothing read it, so it only promised data the row never had.

The landing distance view gets its available distance and no sufficiency
verdict. LandingPerformanceViewModel exposes no available landing distance and
the view passes InterpolationView no maximum, so no exceedance exists to report
to either audience — stating one would invent it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EdB27epdLAZ1x23zkSJ8LT
@RISCfuture
RISCfuture force-pushed the told/a11y-runway-rows branch from 5e8a49f to ad18f8c Compare September 3, 2026 04:54
@RISCfuture
RISCfuture merged commit 69bfbde into main Sep 3, 2026
8 checks passed
@RISCfuture
RISCfuture deleted the told/a11y-runway-rows branch September 3, 2026 10:16
RISCfuture added a commit that referenced this pull request Sep 3, 2026
A runway row had no accessibility modifiers at all, so VoiceOver read it as one
run-on concatenation and heard none of the three warnings the row draws in
color: a distance a NOTAM has shortened, tinted warning; a wind component past
the tailwind or crosswind limit, turned red; and a takeoff distance past the
runway available, turned red. Color was the only signal for each, so a VoiceOver
user was told a runway was usable while the screen was saying it was not.

accessibilityCustomContent adds the facts as labelled items without disturbing
the label the row already synthesizes. That matters: the UI suite queries
staticTexts by identifier and parses the numbers out of element.label, so
substituting a label would have broken it.

The row also has to combine its children. Custom content propagates to every
leaf it covers rather than to the row as a whole, so an uncombined row hung all
of it on each of the six leaves the name, distance and wind components produce.
A NavigationLink hid that on the takeoff and landing forms by combining anyway,
but RunwayPicker — a List row that gets its button trait from a modifier —
combines nothing, and that is the screen where runways are compared: VoiceOver
stopped six times per runway and repeated every high-importance item at each
stop. The identifier and the button trait are applied outside the row and still
land on the combined element.

Contamination, a shortening, a displaced threshold and each exceeded wind limit
are .high, so VoiceOver speaks them without being asked; the declared distances,
the surface and the slope are .default and wait in the rotor. A shortening is
described as the reduction it is rather than as a closure, because a closure is
not something NOTAM can express — a runway closed outright reaches the model
only as a shortening the length of the runway.

The row reads runway.notam, the relationship RunwayDistances already reaches
through, and the unused notam property it declared instead is gone: no caller
passed it and nothing read it, so it only promised data the row never had.

The landing distance view gets its available distance and no sufficiency
verdict. LandingPerformanceViewModel exposes no available landing distance and
the view passes InterpolationView no maximum, so no exceedance exists to report
to either audience — stating one would invent it.


Claude-Session: https://claude.ai/code/session_01EdB27epdLAZ1x23zkSJ8LT

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant