Canvas accessibility: a semantics model for lines, canvas objects and keyboard navigation #47

Open
opened 2026-08-27 03:35:35 +00:00 by ImBenji · 1 comment
Owner

Follow-on to #45. That issue's phase 4 shipped a readable floor for the canvas: lib/pages/map/canvas/ui/canvas_semantics.dart builds a parallel semantics tree with a summary node ("2 lines, 47 stations") plus one node per on-screen station, sorted into visual reading order, labelled with name + subtitle + lines served, reporting selection and activating to select. Ten tests in test/canvas_semantics_test.dart. That makes the map readable. It does not make it navigable or editable, and the remaining work is a different shape — design, not annotation — which is why it is split out here.

Lines are not objects. You can hear that a station is "on Bakerloo and Jubilee" but cannot ask what is on the Bakerloo line, or traverse it. This is the biggest hole, because a metro map's meaning is topological while the current reading order is purely spatial. The blocker is real: nothing in the codebase orders stations along a line. MetroLine carries no station list, connectivity lives in the segment/node graph, and there is no stationsAlong(line) helper. Needs a graph walk over segments that handles branches, loops and interchanges. Largest single piece of work here.

Canvas objects are entirely absent. Five types in lib/models/canvas_objects/ — text label, decorated box, reference image, render target, prefab instance — none represented. A map whose title, legend and key are text labels reads as a map with no title, legend or key. Same shape of work as what already landed, so this is the cheap one.

Keyboard navigation of the canvas. The canvas already takes focus (Focus(autofocus: true), canvas.dart:860) but there is nothing to do once focused — no arrow-key movement between stations, no tab order. Screen-reader traversal reaches the nodes; a keyboard-only user with no screen reader cannot. WCAG 2.1.1, separate from name/role/value, and the most likely thing to fail an authority audit.

Off-screen content is unreachable. The layer culls to the viewport, which keeps the tree honest but means only the scrolled-into-view part of the map is perceivable, with no way to reveal the rest. Note Semantics exposes no onShowOnScreen at the widget level — the reachable hooks are onTap, onScrollLeft/Right/Up/Down, onIncrease/Decrease, onDismiss, onExpand/Collapse and onDidGainAccessibilityFocus. So the fix is either wiring the scroll actions to pan the viewport or panning on accessibility focus, and it needs a decision about what "scrolling" means on an infinite canvas.

Nothing is announced when the map changes. Add a station, move one, undo — silence. Wants a live region fed from the command engine.

No authoring actions. onTap selects; there is no semantic path to move, rename, delete or connect. Whether this matters depends on whether the claim is "accessible viewer" or "accessible editor" — for the STRATEGY.md §12 template-app case it is the viewer that is regulated, not the editor. Worth an explicit decision rather than drifting into it.

Unmeasured perf. The layer rebuilds a Stack of N nodes inside the LayoutBuilder, so it re-runs every pan and zoom frame. Culling bounds it to what is visible, but it has not been profiled on a large map. Probe before shipping, do not pre-optimise.

Suggested order: canvas objects, then keyboard navigation, then off-screen reveal, then line topology last as the piece that needs designing rather than annotating.

Follow-on to #45. That issue's phase 4 shipped a readable floor for the canvas: `lib/pages/map/canvas/ui/canvas_semantics.dart` builds a parallel semantics tree with a summary node ("2 lines, 47 stations") plus one node per on-screen station, sorted into visual reading order, labelled with name + subtitle + lines served, reporting selection and activating to select. Ten tests in `test/canvas_semantics_test.dart`. That makes the map readable. It does not make it navigable or editable, and the remaining work is a different shape — design, not annotation — which is why it is split out here. **Lines are not objects.** You can hear that a station is "on Bakerloo and Jubilee" but cannot ask what is on the Bakerloo line, or traverse it. This is the biggest hole, because a metro map's meaning is topological while the current reading order is purely spatial. The blocker is real: nothing in the codebase orders stations along a line. `MetroLine` carries no station list, connectivity lives in the segment/node graph, and there is no `stationsAlong(line)` helper. Needs a graph walk over segments that handles branches, loops and interchanges. Largest single piece of work here. **Canvas objects are entirely absent.** Five types in `lib/models/canvas_objects/` — text label, decorated box, reference image, render target, prefab instance — none represented. A map whose title, legend and key are text labels reads as a map with no title, legend or key. Same shape of work as what already landed, so this is the cheap one. **Keyboard navigation of the canvas.** The canvas already takes focus (`Focus(autofocus: true)`, `canvas.dart:860`) but there is nothing to do once focused — no arrow-key movement between stations, no tab order. Screen-reader traversal reaches the nodes; a keyboard-only user with no screen reader cannot. WCAG 2.1.1, separate from name/role/value, and the most likely thing to fail an authority audit. **Off-screen content is unreachable.** The layer culls to the viewport, which keeps the tree honest but means only the scrolled-into-view part of the map is perceivable, with no way to reveal the rest. Note `Semantics` exposes no `onShowOnScreen` at the widget level — the reachable hooks are `onTap`, `onScrollLeft/Right/Up/Down`, `onIncrease/Decrease`, `onDismiss`, `onExpand/Collapse` and `onDidGainAccessibilityFocus`. So the fix is either wiring the scroll actions to pan the viewport or panning on accessibility focus, and it needs a decision about what "scrolling" means on an infinite canvas. **Nothing is announced when the map changes.** Add a station, move one, undo — silence. Wants a live region fed from the command engine. **No authoring actions.** `onTap` selects; there is no semantic path to move, rename, delete or connect. Whether this matters depends on whether the claim is "accessible viewer" or "accessible editor" — for the STRATEGY.md §12 template-app case it is the viewer that is regulated, not the editor. Worth an explicit decision rather than drifting into it. **Unmeasured perf.** The layer rebuilds a `Stack` of N nodes inside the `LayoutBuilder`, so it re-runs every pan and zoom frame. Culling bounds it to what is visible, but it has not been profiled on a large map. Probe before shipping, do not pre-optimise. Suggested order: canvas objects, then keyboard navigation, then off-screen reveal, then line topology last as the piece that needs designing rather than annotating.
ImBenji added the enhancementarea:ui/ux labels 2026-08-27 03:35:35 +00:00
ImBenji added this to the Arcs & Angles project 2026-08-27 03:35:35 +00:00
Author
Owner

Scope decided: both viewer and editor. The "no authoring actions" item above is in scope, not just the read path — semantic actions for move, rename, delete and connect. It also promotes keyboard navigation from nice-to-have to load-bearing: an editor you cannot drive from the keyboard is not an accessible editor regardless of how well it reads.

Perf item is now closed. Measured rather than guessed, and it turned up two real problems.

Baseline, 3000 stations / 30 lines, 60-frame pan sweep: 9.0ms/frame. Broken down against an empty-tree control, build() itself was only 0.72ms — the rest was ~11ms/frame of widget reconciliation and ~17ms/frame of semantics compile. So the cost was never the culling loop, it was rebuilding and recompiling ~140 nodes every single frame.

Two fixes, both in lib/pages/map/canvas/ui/canvas_semantics.dart:

  1. Gate on SemanticsBinding.instance.semanticsEnabled. The layer returns an empty SizedBox when no assistive-tech client is attached, and listens for the flag flipping so switching VoiceOver on mid-session still works. Users with no screen reader were paying ~11ms/frame of reconciliation for a tree they will never read.
  2. Regenerate on settle, not per frame. A viewport change restarts a 180ms timer and keeps serving the previously built subtree, returning the identical Widget instance so Flutter's reconciliation short-circuits on identical() and skips the subtree outright rather than rebuilding it into the same shape. Content changes (stations, lines, selection) still regenerate immediately — holding those back would leave a screen reader describing a map that no longer exists.
    Result: 9.0ms/frame to 0.18ms/frame, and zero for anyone without assistive tech.

One hypothesis tested and rejected: keying the Positioned children by station id, on the theory that elements could follow their station across a pan. Measured slightly worse (500-station sweep 4.99 to 6.01ms/frame) because every node's geometry changes during a pan anyway, so keyed reconciliation buys nothing and costs the map lookup. There is a comment in the source saying not to re-add them without re-running the perf test.

Guards live in test/canvas_semantics_perf_test.dart (10 tests): node count tracks the viewport not the document, the gate emits nothing when nothing is listening and wakes on attach, a pan in flight does not rebuild, it regenerates on settle, a content change bypasses the debounce, plus three timing ceilings set at roughly 10x the measurement so they trip on a regression rather than on jitter.

**Scope decided: both viewer and editor.** The "no authoring actions" item above is in scope, not just the read path — semantic actions for move, rename, delete and connect. It also promotes keyboard navigation from nice-to-have to load-bearing: an editor you cannot drive from the keyboard is not an accessible editor regardless of how well it reads. **Perf item is now closed.** Measured rather than guessed, and it turned up two real problems. Baseline, 3000 stations / 30 lines, 60-frame pan sweep: 9.0ms/frame. Broken down against an empty-tree control, `build()` itself was only 0.72ms — the rest was ~11ms/frame of widget reconciliation and ~17ms/frame of semantics compile. So the cost was never the culling loop, it was rebuilding and recompiling ~140 nodes every single frame. Two fixes, both in `lib/pages/map/canvas/ui/canvas_semantics.dart`: 1. **Gate on `SemanticsBinding.instance.semanticsEnabled`.** The layer returns an empty `SizedBox` when no assistive-tech client is attached, and listens for the flag flipping so switching VoiceOver on mid-session still works. Users with no screen reader were paying ~11ms/frame of reconciliation for a tree they will never read. 2. **Regenerate on settle, not per frame.** A viewport change restarts a 180ms timer and keeps serving the previously built subtree, returning the *identical* `Widget` instance so Flutter's reconciliation short-circuits on `identical()` and skips the subtree outright rather than rebuilding it into the same shape. Content changes (stations, lines, selection) still regenerate immediately — holding those back would leave a screen reader describing a map that no longer exists. Result: **9.0ms/frame to 0.18ms/frame**, and zero for anyone without assistive tech. One hypothesis tested and rejected: keying the `Positioned` children by station id, on the theory that elements could follow their station across a pan. Measured slightly worse (500-station sweep 4.99 to 6.01ms/frame) because every node's geometry changes during a pan anyway, so keyed reconciliation buys nothing and costs the map lookup. There is a comment in the source saying not to re-add them without re-running the perf test. Guards live in `test/canvas_semantics_perf_test.dart` (10 tests): node count tracks the viewport not the document, the gate emits nothing when nothing is listening and wakes on attach, a pan in flight does not rebuild, it regenerates on settle, a content change bypasses the debounce, plus three timing ceilings set at roughly 10x the measurement so they trip on a regression rather than on jitter.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: IMBENJI.NET/Metro-Map-Maker#47