diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift new file mode 100644 index 0000000..1a0cbf7 --- /dev/null +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -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" + } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index c236e76..d139f98 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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 diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index 2153585..ee8c619 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -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) } } diff --git a/Kanban/UI/Board/LaneResizeHandle.swift b/Kanban/UI/Board/LaneResizeHandle.swift index 8113045..b6fd64f 100644 --- a/Kanban/UI/Board/LaneResizeHandle.swift +++ b/Kanban/UI/Board/LaneResizeHandle.swift @@ -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() { diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 2cf28af..4aaf993 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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` 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. diff --git a/Kanban/UI/Board/TrashCommands.swift b/Kanban/UI/Board/TrashCommands.swift index 8b3a2c5..d94bc82 100644 --- a/Kanban/UI/Board/TrashCommands.swift +++ b/Kanban/UI/Board/TrashCommands.swift @@ -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 + ] + ) } } diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index 70b2329..35807c8 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -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) } diff --git a/KanbanTests/AccessibilityPhrasesTests.swift b/KanbanTests/AccessibilityPhrasesTests.swift new file mode 100644 index 0000000..9c562da --- /dev/null +++ b/KanbanTests/AccessibilityPhrasesTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import Kanban + +/// The board's spoken vocabulary — 10-accessibility.md ▸ The board through VoiceOver, which states +/// the tree as sentences: +/// +/// > A lane container is labeled "⟨title⟩, lane, N cards" — the count reads the search filter like +/// > the visible badge. The lane header's new-card button is a labeled child ("New card in ⟨lane⟩"). +/// > A card is one flattened element: label = title (or the untitled placeholder), value carries the +/// > attachment count when present, selected state via trait … cut cards expose their dimmed pending +/// > state in the value ("cut, pending paste"). +/// +/// Every one of those is a rule about *text*, and `AccessibilityPhrases` is where they are decided, +/// so this is where they can be held to account without a window or a screen reader. + +@Suite("AccessibilityPhrases") +struct AccessibilityPhrasesTests { + + // MARK: - The untitled placeholder + + @Test("A titled item speaks its title") + func titledItem() { + #expect(AccessibilityPhrases.displayTitle("Fix login") == "Fix login") + } + + @Test("An untitled item speaks the same placeholder the face draws") + func untitledItem() { + #expect(AccessibilityPhrases.displayTitle(nil) == "Untitled") + } + + /// A committed-empty rename removes the `title` key, but a hand-written `title: ""` is a value + /// the parser keeps — and an element labeled with the empty string is an element with no name. + @Test("An empty title reads as the placeholder, not as nothing") + func emptyTitle() { + #expect(AccessibilityPhrases.displayTitle("") == "Untitled") + } + + // MARK: - Lane containers + + @Test("A lane container is ⟨title⟩, lane, N cards") + func laneLabel() { + #expect(AccessibilityPhrases.laneLabel(title: "Doing", cards: 3) == "Doing, lane, 3 cards") + } + + @Test("The lane's card count folds its plural") + func laneLabelSingular() { + #expect(AccessibilityPhrases.laneLabel(title: "Doing", cards: 1) == "Doing, lane, 1 card") + } + + /// "Lanes are never filtered out … a lane the query empties shows 0 and keeps its slot" + /// (04-interactions.md § Search) — so zero is a real, speakable state, not an absence. + @Test("An emptied lane says zero rather than going quiet") + func laneLabelEmpty() { + #expect(AccessibilityPhrases.laneLabel(title: "Done", cards: 0) == "Done, lane, 0 cards") + } + + @Test("An untitled lane still reads as a lane with a count") + func laneLabelUntitled() { + #expect(AccessibilityPhrases.laneLabel(title: nil, cards: 2) == "Untitled, lane, 2 cards") + } + + /// The lane's spoken count and the trash's are one function, so they can never fold a plural + /// two different ways. + @Test("A lane's count phrase and the trash's are the same phrase") + func countsShareOneFolding() { + for count in [0, 1, 2, 41] { + #expect(AccessibilityPhrases.cardCount(count) == TrashModel.phrase(count)) + } + } + + // MARK: - The new-card button + + @Test("The header button names its lane") + func newCardLabel() { + #expect(AccessibilityPhrases.newCardLabel(lane: "Doing") == "New card in Doing") + } + + @Test("The header button of an untitled lane names the placeholder") + func newCardLabelUntitled() { + #expect(AccessibilityPhrases.newCardLabel(lane: nil) == "New card in Untitled") + } + + // MARK: - Card elements + + @Test("A card's label is its title") + func cardLabel() { + #expect(AccessibilityPhrases.cardLabel(title: "Fix login") == "Fix login") + #expect(AccessibilityPhrases.cardLabel(title: nil) == "Untitled") + } + + /// "Value carries the attachment count **when present**" — an ordinary card has no value at all, + /// and an empty AXValue speaks as nothing. + @Test("A plain card carries no value") + func cardValueEmpty() { + #expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: false).isEmpty) + } + + @Test("Attachments ride the value, plural-folded") + func cardValueAttachments() { + #expect(AccessibilityPhrases.cardValue(attachments: 1, isCutPending: false) == "1 attachment") + #expect(AccessibilityPhrases.cardValue(attachments: 4, isCutPending: false) == "4 attachments") + } + + @Test("A cut-pending card says so") + func cardValueCutPending() { + #expect(AccessibilityPhrases.cardValue(attachments: 0, isCutPending: true) == "cut, pending paste") + } + + /// Both fragments in one value, attachments first: the count is a fact about the card, the cut + /// is a fact about what is about to happen to it. + @Test("A cut card with files carries both fragments") + func cardValueBoth() { + #expect( + AccessibilityPhrases.cardValue(attachments: 2, isCutPending: true) + == "2 attachments, cut, pending paste" + ) + } + + // MARK: - The trash column + + @Test("The trash label is stable and its value is its count") + func trashContainer() { + #expect(AccessibilityPhrases.trashLabel == "Trash") + #expect(AccessibilityPhrases.trashValue(cards: 41) == "41 cards") + #expect(AccessibilityPhrases.trashValue(cards: 1) == "1 card") + #expect(AccessibilityPhrases.trashValue(cards: 0) == "0 cards") + } + + /// The announcement states the resulting state rather than the action, so a user who mis-hit the + /// toggle learns where the board ended up. + @Test("Toggling trash visibility announces the resulting state") + func trashVisibility() { + #expect(AccessibilityPhrases.trashVisibility(shown: true) == "Trash shown") + #expect(AccessibilityPhrases.trashVisibility(shown: false) == "Trash hidden") + } +}