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
+104 -17
View File
@@ -163,12 +163,24 @@ struct CardFaceView: View {
openCard(card.id)
})
.contextMenu { boardMenu(openCard: openCard) }
// **The menu's rows, additionally as custom actions** "where SwiftUI additionally
// surfaces menu items as custom accessibility actions, that's free improvement, not
// a separate design surface" (10-accessibility.md Actions come from the context
// menu). The menu stays the inventory and stays reachable the standard way (VO--M).
// Style is absent for `LaneView`'s reason: it opens a popover, and the quick-style
// swatch `Picker` beside it is not an action.
.accessibilityActions { boardActions(openCard: openCard) }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
case let .trash(confirmations):
face
.contextMenu { trashMenu(confirmations: confirmations) }
// The trash's two rows and **no third** "there is no Open"
// (10-accessibility.md Trash lane; 03-board-ui.md's no-editing-in-the-trash). The
// absence is structural on this side too: `openCard` is the board case's payload, so
// there is nothing here an Open action could even call.
.accessibilityActions { trashActions(confirmations: confirmations) }
}
}
@@ -204,6 +216,33 @@ struct CardFaceView: View {
// has anything to act on there `LaneView.renderedCards`.)
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
.contentShape(Rectangle())
// **A card is one flattened accessibility element** (10-accessibility.md The board through
// VoiceOver): "label = title (or the untitled placeholder), value carries the attachment
// count when present, selected state via trait. Face icon and chips are decorative folded
// into the element, never separately focusable". So the icon, the accent stripe and the
// paperclip contribute nothing of their own the count they stood for rides the value below.
//
// `.contain` while a rename is open, `LaneView`'s header rule for its reason: flattening
// would swallow the text field the user is typing into. Board-only by construction, since
// `isRenaming` is (`CardFaceRole`).
.accessibilityElement(children: isRenaming ? .contain : .ignore)
.accessibilityLabel(AccessibilityPhrases.cardLabel(title: card.title.value))
// The attachment count, the deferred cut's "cut, pending paste", or both and the empty
// string when neither, which speaks as nothing (see `AccessibilityPhrases.cardValue` for why
// it is not a conditional modifier).
.accessibilityValue(AccessibilityPhrases.cardValue(
attachments: card.attachments.count,
isCutPending: store.transient.pendingCut.ids.contains(card.id)
))
// "Selection state is always readable from the element (trait)" the other half of "state
// is never colour-alone", whose visible half is the accent stroke above.
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
// **VO-Space toggles this card's selection** "moving the VoiceOver cursor never mutates
// selection. VO-Space on a card toggles its selection (the -click analogue a toggle,
// never plain click's replace)". Routed through the same `BoardStore.click` funnel the
// pointer uses, with the modifier, so the homogeneity rule and the container boundary are
// `SelectionGrammar`'s single answer rather than a second one written here.
.accessibilityAction { toggleSelection() }
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
@@ -412,10 +451,8 @@ struct CardFaceView: View {
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
// this card to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked card outright.
Button("Rename") {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
.disabled(!store.acceptsBoardMutations)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
@@ -424,10 +461,19 @@ struct CardFaceView: View {
// Delete: File Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
// widened target set below (`targetIDs`) the successor-selection rule is `delete(_:)`'s own,
// so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// `boardMenu`'s plain rows as VoiceOver custom actions every one calling the *same* private
/// method its menu row does, so the two surfaces cannot come to mean different things.
@ViewBuilder
private func boardActions(openCard: @escaping (ItemID) -> Void) -> some View {
Button("Open") { openCard(card.id) }
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// Delete and Reveal in Finder the two rows 11-command-nexus.md gives a trash card, and no
@@ -443,16 +489,51 @@ struct CardFaceView: View {
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
@ViewBuilder
private func trashMenu(confirmations: TrashConfirmations) -> some View {
Button("Delete") {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete") { requestPurge(confirmations) }
.disabled(!store.acceptsBoardMutations)
Divider()
Button("Reveal in Finder") {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
Button("Reveal in Finder") { revealInFinder() }
}
/// `trashMenu`'s rows as VoiceOver custom actions `boardActions`' twin, two rows and no Open.
@ViewBuilder
private func trashActions(confirmations: TrashConfirmations) -> some View {
Button("Delete") { requestPurge(confirmations) }
.disabled(!store.acceptsBoardMutations)
Button("Reveal in Finder") { revealInFinder() }
}
// MARK: - The rows' bodies
/// Board Rename's store path, seeded with the card's live title.
private func beginRename() {
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
}
/// File Delete's store path over the context-menu target set.
private func deleteTargets() {
store.delete(targetIDs)
}
/// The trash's **permanent** delete, through the window's confirmation host never straight to
/// the store, because the alert is what stands between this row and an unrecoverable loss.
private func requestPurge(_ confirmations: TrashConfirmations) {
confirmations.requestTrashDelete(of: targetIDs, in: store)
}
private func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
}
/// VO-Space's landing: the -click funnel, on this card, **in this face's container** so a
/// trash card's toggle can no more mix with a board selection than a -click could.
private func toggleSelection() {
store.click(
SelectionTarget(id: card.id, kind: .card, container: role.container),
modifier: .command
)
}
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
@@ -536,14 +617,20 @@ struct CardFaceView: View {
/// The one face chip in scope shown only when the card actually has files, and quiet enough
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
/// accessibility label rather than onto the face: it is useful to know, not to look at.
/// accessibility *value* rather than onto the face: it is useful to know, not to look at.
///
/// **Decorative, and hidden outright** (10-accessibility.md): "face icon and chips are
/// decorative folded into the element, never separately focusable the flattened element
/// carries the attachment count in its value". The flattening above would drop a label here
/// anyway; saying it explicitly is what keeps the chip inert in the replica too, which is drawn
/// outside the flattened face.
@ViewBuilder
private var attachmentsIndicator: some View {
if !card.attachments.isEmpty {
Image(systemName: "paperclip")
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityLabel("\(card.attachments.count) attachments")
.accessibilityHidden(true)
}
}
+6
View File
@@ -73,6 +73,12 @@ struct LaneResizeHandle: View {
popCursor()
}
)
// **Pointer-only, and out of the tree** "the header context menu's width stepper and
// its keyboard face, the Increase/Decrease Lane Width menu items is the accessible
// path; edge drag is enhancement only" (10-accessibility.md Moving without dragging).
// An invisible strip that can only be dragged is a stop with nothing behind it, and it
// would sit between two lane containers in the strip's traversal.
.accessibilityHidden(true)
}
private func pushCursor() {
+113 -10
View File
@@ -109,12 +109,46 @@ struct LaneView: View {
// the strip's logic, external Finder file sessions against those same zones because
// single-target dispatch has no fall-through (DRAG-REORDER.md).
.onDrop(of: boardDropTypes, delegate: LaneDropDelegate(context: drops, laneID: lane.id))
// **The lane is an accessibility container** "window lanes (accessibility containers, in
// lane `order`) cards (leaf elements, in card `order`)" (10-accessibility.md The board
// through VoiceOver). `.contain` rather than `.combine`: the header, the new-card button and
// every card must stay individually reachable, which is the whole point of a container the
// VoiceOver cursor enters (`TrashLaneView` states the same rule from the trash's side).
.accessibilityElement(children: .contain)
// "title, lane, N cards", where **N is the rendered count and therefore the filter's**
// the very collection the visible badge counts, so the spoken count and the drawn one are
// one number ("the count reads the search filter like the visible badge"). A card the query
// hid is never built, so it leaves the masonry and the accessibility tree in the same pass,
// which is 10's "filtered-out cards leave layout and the accessibility tree together" holding
// by construction rather than by a second rule.
.accessibilityLabel(AccessibilityPhrases.laneLabel(title: lane.title.value, cards: renderedCards.count))
}
// MARK: - Header
private var header: some View {
headerContent
// **The lane title is a heading** "lane titles are headings, so the headings rotor
// jumps lane-to-lane; on a one-dimensional board that *is* structural navigation"
// (10-accessibility.md Rotor). One flattened element rather than icon + text + badge:
// the glyph and the count are the container's information, already spoken by its label,
// and three stops where the design asks for a heading would make the rotor useless.
//
// `.contain` while a rename is open, because the flattening would otherwise swallow the
// text field the user is typing into the one moment this subtree holds a control
// rather than chrome.
.accessibilityElement(children: isRenaming ? .contain : .ignore)
.accessibilityLabel(AccessibilityPhrases.displayTitle(lane.title.value))
.accessibilityAddTraits(headerTraits)
// **VO-Space toggles the lane's selection** the -click analogue 10-accessibility.md
// gives a card, applied to the other selectable thing on the board, and routed through
// the same `BoardStore.click` funnel the pointer uses so the homogeneity and
// container rules are `SelectionGrammar`'s single answer rather than a second one.
// Deliberately **not** the header's own plain-click semantics: "moving the VO cursor
// never mutates selection VO-Space on a card toggles its selection (the -click
// analogue a toggle, never plain click's replace)", and a VO-Space that replaced would
// silently wipe a multi-lane selection the user had just built.
.accessibilityAction { toggleLaneSelection() }
// The bar is the drag surface, so it must be hit-testable across its whole width
// including the empty stretch between the badge and the button.
.contentShape(Rectangle())
@@ -143,6 +177,14 @@ struct LaneView: View {
.onDisappear { drops.registry.removeHeader(lane.id) }
.overlay(alignment: .trailing) { newCardButton }
.contextMenu { laneMenu }
// **The context menu's plain rows, additionally as custom actions** "where SwiftUI
// additionally surfaces menu items as custom accessibility actions, that's free
// improvement, not a separate design surface" (10-accessibility.md). The menu itself
// stays the inventory and is reachable the standard way (VO--M); this is the same four
// commands one rotor turn closer. Style is deliberately absent: it opens a popover
// its own accessible surface and the quick-style swatch `Picker` beside it is not an
// action at all.
.accessibilityActions { laneActions }
// The lane's half of the Style popover. Anchored on the header because that is the
// lane's own furniture `styleEditorPresentation` decides whether this lane is the
// session's presenting anchor at all.
@@ -181,10 +223,8 @@ struct LaneView: View {
// currentTitle:)`, seeded with the lane's live title. The menu-bar item additionally requires
// this lane to be the *sole* selection; a context menu already names its target by where it
// was invoked, so standard macOS practice it acts on the clicked lane outright.
Button("Rename") {
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
}
.disabled(!store.acceptsBoardMutations)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Divider()
@@ -199,10 +239,52 @@ struct LaneView: View {
// Delete: File Delete's exact store path (`store.delete`), on the same widened target set
// Style above reads (`targetIDs`, `styleTarget`'s `Set<ItemID>` sibling below) the
// successor-selection rule is `delete(_:)`'s own, so this row gets it for free.
Button("Delete") {
store.delete(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// The menu's plain rows again, as VoiceOver custom actions (see the `.accessibilityActions`
/// call site). Every one of them calls the *same* private method its menu row does, so the two
/// surfaces cannot drift into meaning different things which is the only way "not a separate
/// design surface" is checkable rather than merely intended.
@ViewBuilder
private var laneActions: some View {
let units = LaneLayoutMath.displayUnits(of: lane)
Button("Rename") { beginRename() }
.disabled(!store.acceptsBoardMutations)
Button("Increase Width") { store.setLaneWidth(lane.id, units: units + 1) }
.disabled(!store.acceptsBoardMutations)
Button("Decrease Width") { store.setLaneWidth(lane.id, units: units - 1) }
.disabled(!store.acceptsBoardMutations || units <= 1)
Button("Delete") { deleteTargets() }
.disabled(!store.acceptsBoardMutations)
}
/// Board Rename's store path, seeded with the lane's live title one method, two callers
/// (the context menu row and its accessibility twin).
private func beginRename() {
store.transient.beginRename(of: lane.id, currentTitle: lane.title.value)
}
/// File Delete's store path over the context-menu target set the menu row's body and its
/// accessibility twin's alike.
private func deleteTargets() {
store.delete(targetIDs)
}
/// VO-Space's landing: the -click funnel, on this lane. `togglesOnRepeat` stays false because
/// only the *plain* branch reads it the branch is already a toggle, which is the point.
private func toggleLaneSelection() {
store.click(
SelectionTarget(id: lane.id, kind: .lane, container: .board),
modifier: .command
)
}
/// The header element's traits: a heading always, and **selected when the lane is** "state is
/// never colour-alone: selection is a ring plus trait" (10-accessibility.md).
private var headerTraits: AccessibilityTraits {
isSelected ? [.isHeader, .isSelected] : [.isHeader]
}
/// The width stepper "the header context menu's Width control (stepper, uncapped) is the
@@ -330,7 +412,10 @@ struct LaneView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("New card in \(lane.title.value ?? "Untitled")")
// "The lane header's new-card button is a labeled child ('New card in lane')"
// (10-accessibility.md The board through VoiceOver) the header's one child element, which
// is why it lives in an overlay outside the flattened bar rather than inside it.
.accessibilityLabel(AccessibilityPhrases.newCardLabel(lane: lane.title.value))
// Mutating, so the read-only lock disables it like every other write path
// (02-architecture.md § The lock's scope), and the focused-editor rule closes it while an
// inline editor is open (04 Grammar) the pointer twin of a disabled menu item.
@@ -466,7 +551,7 @@ struct LaneView: View {
// `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly
// `units` columns of `standard` (03-board-ui.md § Layout full visibility).
MasonryLayout(columns: columns, spacing: cardSpacing) {
ForEach(slots) { slot in
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
Group {
switch slot {
case let .card(card):
@@ -502,6 +587,24 @@ struct LaneView: View {
// holds identically under Reduce Motion: a transition that does not fire has no
// variant to choose between.
.transition(Motion.cardTransition(reduced: reduceMotion))
// **VoiceOver reads the masonry by `order`, not by column** 10-accessibility.md
// Logical order, not masonry position (decided): "within a wide lane,
// VoiceOver reads cards by `order` the interior grid columns are presentation
// only. This deliberately diverges from on-screen geometry."
//
// The divergence is real and it is why an explicit priority is needed at all:
// `MasonryLayout` assigns child `i` to column `i % columns`, so in a 3-unit lane
// the second card by `order` is drawn to the *right* of the first, not below it
// and an accessibility tree sorted by geometry (which is what a container does
// without this) would read the board column-major: 1, 4, 7, 2, 5, 8 , an order
// that exists nowhere in the model, on disk, or in the keyboard grammar.
// Priority descends with the slot index, so the highest reads first and the list
// is exactly `slots` the same sequence the masonry is handed and the same one
// `SelectionGrammar` flattens.
//
// The drag shadows are inert here: `DragShadow` hides itself from the tree, and
// a slot that is not an element consumes no priority.
.accessibilitySortPriority(Double(slots.count - index))
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
// resolves against an explicit `.id`, and it goes outermost so the transition
// above stays inside the identified view rather than around it.
+27
View File
@@ -1,3 +1,4 @@
import AppKit
import Observation
import SwiftUI
@@ -290,6 +291,32 @@ extension BoardStore {
clearSelection()
}
}
announceTrashVisibility(shown)
}
/// **"Toggling visibility is announced"** (10-accessibility.md Trash lane).
///
/// A whole container joins or leaves the accessibility tree here and nothing else marks it: the
/// VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
/// silent by nature. Announced from the store rather than from either caller for
/// `setTrashVisible`'s own reason the View menu row and the toolbar item are one command with
/// two faces, and a consequence written at one of them would be missing from the other.
///
/// One post, and deliberately no machinery around it: the live board's announcements foreign
/// edits, vanishing focus, bracketed operations are their own design (10 Live board
/// announcements) with a summarizer and a debounce behind them, and this is not an instalment of
/// that. Posted to the key window so it is attributed to the board the user is looking at, at
/// medium priority: informative, and not worth interrupting speech already in progress.
private func announceTrashVisibility(_ shown: Bool) {
let element: Any = NSApplication.shared.keyWindow ?? NSApplication.shared
NSAccessibility.post(
element: element,
notification: .announcementRequested,
userInfo: [
.announcement: AccessibilityPhrases.trashVisibility(shown: shown),
.priority: NSAccessibilityPriorityLevel.medium.rawValue
]
)
}
}
+11 -3
View File
@@ -117,8 +117,10 @@ struct TrashLaneView: View {
// stay individually reachable combining them would collapse the container the design asks
// VoiceOver to enter.
.accessibilityElement(children: .contain)
.accessibilityLabel("Trash")
.accessibilityValue(TrashModel.phrase(renderedCards.count))
.accessibilityLabel(AccessibilityPhrases.trashLabel)
// The **rendered** count, like a lane's: the shown trash's cards participate in the filter,
// so a query narrows the spoken count exactly as it narrows the badge and the column itself.
.accessibilityValue(AccessibilityPhrases.trashValue(cards: renderedCards.count))
}
/// The cards the column shows.
@@ -257,7 +259,7 @@ struct TrashLaneView: View {
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
// because a trash is small: it holds one board's deletions, and Empty Trash exists.
MasonryLayout(columns: 1, spacing: cardSpacing) {
ForEach(slots) { slot in
ForEach(Array(slots.enumerated()), id: \.element.id) { index, slot in
Group {
switch slot {
case let .card(card):
@@ -282,6 +284,12 @@ struct TrashLaneView: View {
// should read alike from either side of the strip. The transaction is the
// reload's, like the lanes' (`Motion.reloadAnimates`).
.transition(Motion.cardTransition(reduced: reduceMotion))
// `order`-keyed traversal, `LaneView`'s rule on the trash side. The column is
// one masonry column, so geometry and `order` agree here and the priority is
// belt over braces written anyway because the *reason* it agrees is the
// column's fixed one width unit, which is a layout fact rather than a
// traversal guarantee, and a two-unit trash would silently read column-major.
.accessibilitySortPriority(Double(slots.count - index))
// The scroll target `LaneView`'s rule, and outermost for its reason.
.id(slot.id)
}