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
+124
View File
@@ -0,0 +1,124 @@
import Foundation
// MARK: - AccessibilityPhrases
/// **What the board's elements say** every label and value VoiceOver reads off the board window,
/// composed by pure functions (10-accessibility.md The board through VoiceOver).
///
/// ### Why the strings live here and not at the modifiers
///
/// 10-accessibility.md states the board's tree as *sentences*: a lane container is
/// "title, lane, N cards", the header button is "New card in lane", a card's value carries its
/// attachment count and, when it is cut-pending, "cut, pending paste". Those are rules about text
/// which placeholder an untitled item wears, how a count folds its plural, what order two value
/// fragments join in and a rule about text is only checkable if there is a function to ask. Split
/// out, the whole spoken vocabulary is pinned by `AccessibilityPhrasesTests` without a window, a
/// screen reader, or a running app; `Motion`, `TrashModel.purgePrompt` and `HistoryPhrase` are the
/// same shape for the same reason.
///
/// It is also the one place the board and the trash column can be made to *agree*: the lane's spoken
/// count and the trash's are one function, the untitled placeholder is one constant, and the
/// attachment phrase the card face used to spell inline is now the same string the flattened card
/// element carries in its value.
enum AccessibilityPhrases {
// MARK: - Shared vocabulary
/// The untitled placeholder **the same word the face draws** (`LaneView.headerTitle`,
/// `CardFaceView.titleOrEditor`), because 10-accessibility.md asks for "label = title (or the
/// untitled placeholder)" and a spoken placeholder that differed from the visible one would make
/// a sighted user and a VoiceOver user describe different boards.
static let untitled = "Untitled"
/// An item's spoken name: its title, or the untitled placeholder. Total, so no caller branches.
static func displayTitle(_ title: String?) -> String {
guard let title, !title.isEmpty else { return untitled }
return title
}
/// "3 cards", "1 card" the app's **one** plural folding for a card count, borrowed from
/// `TrashModel.phrase` rather than restated so a lane's spoken count and the trash column's
/// cannot drift apart.
static func cardCount(_ count: Int) -> String {
TrashModel.phrase(count)
}
// MARK: - Lanes
/// A lane container's label "title, lane, N cards" (10-accessibility.md The board through
/// VoiceOver).
///
/// **The count is the caller's, and the caller passes the rendered one**: "the count reads the
/// search filter like the visible badge", so `LaneView` hands the very collection its badge
/// counts (`renderedCards`) and the two can no more disagree than the badge can disagree with
/// the masonry.
static func laneLabel(title: String?, cards count: Int) -> String {
"\(displayTitle(title)), lane, \(cardCount(count))"
}
/// The lane header's new-card button "New card in lane", the one labeled child
/// 10-accessibility.md gives the header.
static func newCardLabel(lane title: String?) -> String {
"New card in \(displayTitle(title))"
}
// MARK: - Cards
/// A card's label: its title, or the untitled placeholder. Named rather than inlined so the
/// card element and the lane's own title read through one function.
static func cardLabel(title: String?) -> String {
displayTitle(title)
}
/// "1 attachment", "4 attachments" the paperclip chip's information, moved into the card
/// element's value where 10-accessibility.md puts it ("the flattened element carries the
/// attachment count in its value"). Plural-folded like every other count in the app; the chip
/// itself used to say "N attachments" unconditionally, which read wrong at one.
static func attachmentCount(_ count: Int) -> String {
"\(count) attachment\(count == 1 ? "" : "s")"
}
/// The deferred cut's spoken half "cut items dim in place until paste moves them"
/// (04-interactions.md Clipboard), and **state is never colour-alone** (10-accessibility.md):
/// the dim is the sighted signal, this is the other one.
static let cutPending = "cut, pending paste"
/// A card element's value the attachment count when it has files, the cut-pending phrase when
/// it is staged for paste, both when both, and **the empty string when neither**.
///
/// Empty rather than `nil` on purpose: the modifier that consumes it is unconditional, because a
/// `if` around `.accessibilityValue` would put the whole card face inside a `_ConditionalContent`
/// that flips identity and therefore rebuilds the face, dropping its measured height and its
/// marquee registration the moment an attachment lands or a cut is pasted. An empty AXValue
/// speaks as nothing, which is exactly what "no value" should sound like.
static func cardValue(attachments: Int, isCutPending: Bool) -> String {
var parts: [String] = []
if attachments > 0 { parts.append(attachmentCount(attachments)) }
if isCutPending { parts.append(cutPending) }
return parts.joined(separator: ", ")
}
// MARK: - The trash column
/// The trash container's label stable, like the header's visible title (03-board-ui.md §
/// Trash: one word, never "Hide Trash"-style state in the name).
static let trashLabel = "Trash"
/// The trash container's value: its card count, filtered exactly as the lane labels' are "the
/// shown trash's cards participate in the filter exactly like any other card" (03-board-ui.md §
/// Trash), so the column passes the collection its badge counts.
static func trashValue(cards count: Int) -> String {
cardCount(count)
}
/// What View Show Trash announces "toggling visibility is announced"
/// (10-accessibility.md Trash lane). A whole container joining or leaving the board is a
/// layout change with no focus consequence and therefore nothing else to notice it by.
///
/// Phrased as the resulting *state* rather than as the action ("Trash shown", not "Showing
/// trash"), because the toolbar item and the menu checkmark both mean the same thing and a user
/// who mis-hit the toggle needs to know where the board ended up.
static func trashVisibility(shown: Bool) -> String {
shown ? "Trash shown" : "Trash hidden"
}
}
+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)
}