Build the VoiceOver tree and actions

The board window's accessibility tree per DESIGN/10: lanes are containers
labeled "<title>, lane, N cards" (filter-aware count = renderedCards, the
badge's own collection); cards are one flattened element each — label =
title or the untitled placeholder, value = attachment count + "cut,
pending paste", selection via trait; face icon, stripe, and paperclip are
decorative and hidden. Masonry never leaks into traversal: slots carry
order-keyed accessibilitySortPriority, so a wide lane reads by card order,
not column-major. Lane titles carry the heading trait for the rotor.

VO-Space is the ⌘-click analogue routed through the existing
BoardStore.click funnel (SelectionGrammar stays the single answer for
toggle and container-boundary rules) — cards and lane headers both.
Context-menu rows double as custom accessibility actions, each calling
the same private method as its menu row so the surfaces cannot drift;
trash cards expose Delete and Reveal in Finder and never Open. The trash
column is pinned last via sort priority 0, its label/value re-routed
through the new AccessibilityPhrases seam; toggling trash visibility
posts a one-line announcement from the store seam (both command faces).
The invisible lane-resize drag strip leaves the tree — the stepper and
menu items are the accessible width path.

AccessibilityPhrases is the pure vocabulary seam (labels, values, plural
folding shared with TrashModel.phrase), pinned by its own test suite.
Both schemes build; 1466 unit tests green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 07:38:38 -04:00
parent 7ba90a8cc9
commit 273c182ef4
8 changed files with 536 additions and 31 deletions
+14 -1
View File
@@ -247,7 +247,7 @@ struct BoardView: View {
@ViewBuilder
private func laneStrip(_ slots: [StripSlot], standard: CGFloat) -> some View {
HStack(alignment: .top, spacing: spacing) {
ForEach(slots) { slot in
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
switch slot {
case let .lane(lane):
laneSlot(lane, standard: standard)
@@ -257,6 +257,15 @@ struct BoardView: View {
// the reload that carried it (`Motion.reloadAnimates`) a transition with no
// animated transaction around it is simply an appearance.
.transition(Motion.laneTransition(reduced: reduceMotion))
// **Lanes are read in lane `order`** (10-accessibility.md The board
// through VoiceOver). Geometry already agrees an `HStack` lays the slots
// out left to right in this very sequence so unlike the masonry's
// column-major divergence (`LaneView`) this is a statement rather than a
// correction. It is written anyway for what it buys below: the priorities
// stay above the trash's, which is the only way "the trash is the LAST
// container" survives a right-to-left layout direction or a lane slot the
// resize session lifts to `zIndex(1)`.
.accessibilitySortPriority(Double(slots.count - index))
case let .shadow(_, units):
// One of the drag's N contiguous shadows, at the exact width the arriving lane
// will occupy its units measured against *this* strip's standard, which is
@@ -283,6 +292,10 @@ struct BoardView: View {
// unit (03-board-ui.md § Motion, § Trash's re-divide). The transaction is the
// menu toggle's (`ShowTrashCommand`).
.transition(Motion.laneTransition(reduced: reduceMotion))
// "When shown, it is the **last** container" (10-accessibility.md Trash lane)
// below every lane's priority, whatever the lane count, because zero is the floor
// the expression above never reaches.
.accessibilitySortPriority(0)
}
}
// The drag's reflow-to-make-room, keyed on the **drop proposal** and nothing else