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
+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)
}
}