Run the accessibility verification pass

The automated half: AccessibilityAuditTests runs performAccessibilityAudit
over all eight surfaces DESIGN/10 names — board with trash hidden and
shown, card window in Preview/Edit/raw source, welcome, template chooser,
board popover. One audit per test, .all audit types, no issue handler —
nothing waived; a future false-positive excusal must match one element on
one surface with its reason beside it. Navigation is menu-bar titles and
the arrow grammar; no accessibility identifiers added to production code.

The suite launches with --ui-test-fixture-board: the sandbox forbids
handing the app a temp-folder path (no bookmark behind it), so the flag
carries no payload and the app builds a known board inside its own
container through the ordinary BoardWriter door — three lanes, six cards,
a rich Markdown body with attachment, one card already in .trash/ — with
the registry redirected to the same scratch directory so audit runs never
pollute real recents. LaunchPlan replaces the restore Bool (welcome /
restoreBoards / uiTestFixture, fixture wins outright), decided once in
KanbanApp.init and dispatched by RestoreBootstrapView; pure and pinned by
UITestLaunchTests, and the fixture itself is materialized and read back
through BoardLoader in units — the only proof available headlessly.

The manual half: KanbanUITests/AccessibilityVerification.md is the one
document — the audit suite at the top (it needs a real display and
Accessibility permission), the per-release VoiceOver smoke script with
expected utterances quoted from AccessibilityPhrases, and the
consolidated m11 checklist from all four implementation cards.

1588 unit tests green, UI target compiles, both schemes build. The audit
run and smoke script await a real display — the manual pass is the
user's.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 10:42:14 -04:00
parent 92a088fdd3
commit c5edcd8528
9 changed files with 989 additions and 20 deletions
+5 -1
View File
@@ -434,7 +434,11 @@ public final class AppModel {
/// known board and nothing should pay that on every scene-graph evaluation. An `App`'s `init` is
/// not itself reachable from a test, so the decision is pulled out to here: two `Bool`s in, one
/// out, provable without a real `UserDefaults` domain or a live registry.
public static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
///
/// `nonisolated` because it is exactly as pure as that sentence claims it touches no stored
/// state, and `LaunchPlan.decide` (which is not main-actor-bound either, for the same reason)
/// composes it into the three-way launch decision.
public nonisolated static func shouldRestoreAtLaunch(preference: Bool, hasRestorables: Bool) -> Bool {
preference && hasRestorables
}
+46 -3
View File
@@ -12,8 +12,8 @@ import os
/// Window menu whose only job is to run the pass and then dismiss itself. It exists for a few
/// hundred milliseconds and never draws.
///
/// It is presented **only** when there is something to restore (`KanbanApp` decides), so the ordinary
/// launch-to-welcome path never creates it.
/// It is presented **only** when there is something to open (`KanbanApp` decides, via
/// `LaunchPlan.presentsBootstrap`), so the ordinary launch-to-welcome path never creates it.
///
/// ### What the pass does
///
@@ -23,8 +23,18 @@ import os
/// silent drop." Welcome comes up only if nothing was even attempted; a board that *was* attempted
/// and then failed to load opens welcome from its own host, which is the same rule applied one layer
/// down and keeps this pass from having to wait on loads it did not perform.
///
/// ### And one other pass, for the same reason
///
/// The accessibility audit suite's fixture board (`UITestLaunch`) is built and opened here too. It is
/// the same job with a different source filesystem work that must happen before the first real
/// window, needing `openWindow` to finish and giving it a second throwaway window would be a second
/// copy of everything this file explains. Which pass runs is `plan`'s to say and nothing else's.
struct RestoreBootstrapView: View {
/// Decided in `KanbanApp.init()`; this view only dispatches on it.
let plan: LaunchPlan
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@@ -56,6 +66,20 @@ struct RestoreBootstrapView: View {
// the app's first act, and `openBoard` needs the action now.
appModel.captureWindowActions(open: openWindow, dismiss: dismissWindow)
switch plan {
case .uiTestFixture:
openFixtureBoard()
case .restoreBoards, .welcome:
// `.welcome` never presents this window, so it cannot arrive here and if a future
// launch path let it, the restoration pass is the harmless answer: it finds nothing
// flagged and shows welcome, which is what `.welcome` asked for anyway.
restoreFlaggedBoards()
}
dismissWindow(id: WindowID.restoreBootstrap)
}
private func restoreFlaggedBoards() {
var attempted = 0
for board in appModel.boardRegistry.restorables() {
switch board {
@@ -74,6 +98,25 @@ struct RestoreBootstrapView: View {
if attempted == 0 {
appModel.showWelcome()
}
dismissWindow(id: WindowID.restoreBootstrap)
}
/// The audit suite's board: built here, opened through the same `openBoard` every other path
/// uses, so it registers, bookmarks and titles itself exactly like a board the user opened.
///
/// **A failure lands on welcome as an ordinary launch failure**, with the fixture's own path on
/// it. That is deliberate: a suite whose fixture failed to build would otherwise audit an empty
/// screen and pass, which is the one outcome an accessibility gate must never produce.
private func openFixtureBoard() {
do {
let url = try UITestLaunch.materializeFixtureBoard()
appModel.openBoard(at: url)
} catch {
Self.logger.error("the UI-test fixture board could not be built: \(error.localizedDescription, privacy: .public)")
appModel.recordLaunchFailure(
path: UITestLaunch.fixtureBoardURL.path,
message: "The UI-test fixture board could not be built: \(error.localizedDescription)"
)
appModel.showWelcome()
}
}
}
+314
View File
@@ -0,0 +1,314 @@
import Foundation
import os
// MARK: - The launch plan
/// What the app does with its first run-loop turn decided once, in `KanbanApp.init()`, and never
/// re-derived (02-architecture.md § Launch and window lifecycle).
///
/// It exists because there are now **three** answers to "which window appears at launch", not two:
/// welcome, the registry's restoration pass, and for the accessibility audit suite
/// (10-accessibility.md Verification) a synthetic board the app builds for itself. A `Bool` held
/// two of them; a third would have meant two booleans and an implicit precedence between them, which
/// is exactly the shape that grows a launch bug nobody can reproduce.
///
/// The decision is a pure function of three facts, so it is provable without a `UserDefaults` domain,
/// a live registry, or a running app `AppModel.shouldRestoreAtLaunch`'s own reason for being pulled
/// out of `App.init` in the first place, applied one level up.
enum LaunchPlan: Equatable, Sendable {
/// Nothing to restore and no fixture asked for: the welcome window, which is the ordinary
/// first-launch and tidied-away case.
case welcome
/// The registry's flagged boards reopen (`BoardRegistry.restorables()`).
case restoreBoards
/// The UI-test fixture board is built and opened. See `UITestLaunch`.
case uiTestFixture
/// **The fixture wins outright**, and that is the whole precedence rule: a UI-test launch must
/// never reopen the developer's own boards, both because the audit needs a board whose contents
/// the test knows and because a test run has no business touching real documents.
static func decide(
isUITestFixtureLaunch: Bool,
restorePreference: Bool,
hasRestorables: Bool
) -> LaunchPlan {
if isUITestFixtureLaunch { return .uiTestFixture }
return AppModel.shouldRestoreAtLaunch(
preference: restorePreference,
hasRestorables: hasRestorables
) ? .restoreBoards : .welcome
}
/// Whether the throwaway bootstrap window is presented at launch everything except the plain
/// welcome case, since both other plans have to open windows and only a view can do that
/// (`RestoreBootstrapView`'s own reason for wearing a window).
var presentsBootstrap: Bool {
self != .welcome
}
}
// MARK: - UITestLaunch
/// **The accessibility audit suite's board**, and the launch argument that asks for it
/// (10-accessibility.md Verification: "Xcode's accessibility audit runs in UI tests over every
/// surface board (trash shown and hidden), card window (Preview, Edit, raw source), welcome,
/// template chooser, board popover").
///
/// ### Why the app builds the board instead of being handed one
///
/// The obvious shape the UI test writes a board to a temp folder and passes its path **cannot
/// work here, and the reason is the sandbox**. `Kanban.entitlements` grants
/// `files.user-selected.read-write` and nothing else, so a path arriving on the command line is a
/// path the app may not read: there is no open panel behind it and no bookmark for it. The app can
/// only reach files it was granted, files it ships, and its own container.
///
/// So the flag carries no payload and the app builds the board **inside its own container**
/// (`NSTemporaryDirectory()`, which sandboxes to `/Containers/dev.rzen.indie.Kanban/Data/tmp`),
/// through the ordinary `BoardWriter` the app's single write door (02-architecture.md § Layering).
/// Nothing here knows the storage format: it calls `createBoard`, `createLane`, `createCard`,
/// `writeBody`, `importAttachments` and `deleteCardToTrash` exactly as the board window does, so the
/// fixture is a board the app made, not a board a test file *believes* is well-formed. A format
/// change that broke this would break the app first.
///
/// ### It is inert without the flag
///
/// Every entry point below is reached only from `LaunchPlan.uiTestFixture`, and that case is reached
/// only when `--ui-test-fixture-board` is on the command line. No document open, no URL scheme and no
/// menu item can produce it; a shipped app never runs a line of this. It is compiled into the release
/// binary anyway rather than hidden behind `#if DEBUG`, because the thing being audited must be the
/// app that ships an accessibility pass over a differently-compiled binary is a pass over a
/// different app.
///
/// ### What the flag also switches off
///
/// **The registry moves into the scratch directory** with the board. Without that, every audit run
/// would stamp a temp folder into the user's real recents list (`BoardRegistry.defaultStorageURL`,
/// in Application Support), where it would sit for good as an unavailable row pointing at a
/// directory that no longer exists. Tying it to the same flag rather than to a second argument is
/// deliberate: the two are one decision "this launch is synthetic" and a second argument is a
/// second chance to apply only half of it.
///
/// The honest residual: `UserDefaults` is **not** redirected, so an audit run can still write the
/// three app-wide scalars (`AppPreferences`) into the real domain. They are a window size, a restore
/// toggle this launch never consults, and the quick-style recents list no documents, nothing
/// destructive, and redirecting a defaults domain from inside the process is not something the
/// platform actually supports. It is stated rather than fixed.
enum UITestLaunch {
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "ui-test-launch")
// MARK: - The flag
/// The launch argument the audit suite passes (`KanbanUITests/AccessibilityAuditTests.swift`).
///
/// `--`-prefixed on purpose: a single-dash `-key value` pair is swallowed by `UserDefaults`'
/// `NSArgumentDomain` and would silently become a preference, which is precisely the kind of
/// side effect a test-only switch must not have.
static let fixtureFlag = "--ui-test-fixture-board"
/// Whether `arguments` asks for the fixture board **pure**, so the rule is pinned by
/// `UITestLaunchTests` rather than by launching an app and looking.
///
/// Exact match, not a prefix: `--ui-test-fixture-boards-elsewhere` is not this flag, and a
/// `hasPrefix` check that accepted it would be a launch switch with a fuzzy edge.
static func isFixtureLaunch(arguments: [String]) -> Bool {
arguments.contains(fixtureFlag)
}
/// The running process's answer to the same question.
static var isFixtureLaunch: Bool {
isFixtureLaunch(arguments: ProcessInfo.processInfo.arguments)
}
// MARK: - The scratch directory
/// Everything a fixture launch writes, under one removable root inside the app's container.
///
/// One folder rather than two loose paths so `prepareScratchDirectory()` can promise a clean
/// start with a single `removeItem` a fixture board and a registry that disagreed about which
/// run they belonged to would be worse than either being stale.
static var scratchRoot: URL {
URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("LaneworkUITestFixture", isDirectory: true)
}
/// Where the fixture launch's registry lives beside the board rather than in Application
/// Support, which is the whole point (see the type's note).
static var registryStorageURL: URL {
scratchRoot.appendingPathComponent("board-registry.json", isDirectory: false)
}
/// The fixture board's own folder. `.kanban`-suffixed because a board the app made through the
/// ordinary create path is a document, and the audit should be looking at the shape a user's
/// board actually has (01-storage-format.md § Document packaging).
static var fixtureBoardURL: URL {
scratchRoot.appendingPathComponent("\(boardTitle).kanban", isDirectory: true)
}
/// Wipes and recreates the scratch root, and answers the registry URL to build the app model
/// with. Called once, from `KanbanApp.init()`, **before** the model reads its registry.
///
/// **Wiped rather than reused**: every audit test launches its own app instance, and an audit is
/// only meaningful against a board whose contents the test knows a previous run's leftovers
/// (a card the test deleted, a lane it renamed) would make the next run's tree something nobody
/// wrote down. Removal failures are logged and swallowed: the create below will fail loudly and
/// visibly on welcome if the directory is genuinely unusable, and there is no launch this early
/// that an alert could belong to.
@discardableResult
static func prepareScratchDirectory() -> URL {
let root = scratchRoot
do {
try FileManager.default.removeItem(at: root)
} catch CocoaError.fileNoSuchFile {
// The ordinary first-run case, not a failure.
} catch {
logger.error("could not clear the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
}
do {
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
} catch {
logger.error("could not create the UI-test scratch directory: \(error.localizedDescription, privacy: .public)")
}
return registryStorageURL
}
// MARK: - The fixture board's shape
/// The board's title and, through `fixtureBoardURL`, its folder name and its window title, so
/// a test can wait on `app.windows["Audit Board"]`.
static let boardTitle = "Audit Board"
/// The lane titles, in board order. Three because the tree the audit walks should have more than
/// one container to walk *between*, and because the smoke script's cut-and-paste step needs a
/// destination lane that is not the source.
static let laneTitles = ["To Do", "Doing", "Done"]
/// The card titles, per lane, in card order.
///
/// **The first lane is deliberately the crowded one.** Two of the audit's riskiest claims are
/// about a lane wide enough to lay its cards out in interior masonry columns that VoiceOver
/// reads them by `order` and not column-major (10-accessibility.md Logical order), and that the
/// `accessibilitySortPriority`-inside-a-`Layout` mechanism holding that up actually survives and
/// neither is observable in a lane with one card in it.
static let cardTitles: [[String]] = [
["Draft the release notes", "Check the trash grammar", "Confirm the rotor jumps", "Ship the audit"],
["Write the smoke script"],
["Wire the launch fixture"],
]
/// The card that gets the rich body, named by `(lane, card)` index the one the card-window
/// audits open.
///
/// It carries the structures 10-accessibility.md makes claims about ("Preview renders to the
/// accessibility tree as structured text headings navigable by rotor, lists and tables read as
/// such; task-list checkboxes are real accessible checkboxes"), so the Preview audit has
/// something to audit and the Edit and raw-source audits open a document with real content in it
/// rather than an empty text view.
static let richCardIndex = (lane: 1, card: 0)
/// The rich card's body. Every element in it is one 10-accessibility.md names: two heading
/// levels for the rotor, a bulleted list, a task list (live checkboxes), a table, a fenced code
/// block, a link, and an image with alt text ("body images use Markdown alt text when present,
/// else the filename" the file need not exist for the alt text to be the thing under audit).
static let richCardBody = """
## What this card is for
It is the accessibility audit's specimen: every structure 10-accessibility.md makes a claim \
about appears once, so Preview has something to render into the tree.
### Structures
- A bulleted list item
- A second one, with a [link](https://example.com) in it
- [ ] An unchecked task
- [x] A checked task
| Surface | Audited |
| --- | --- |
| Board | Yes |
| Card window | Yes |
```swift
let audit = try app.performAccessibilityAudit()
```
![A diagram of the board's accessibility tree](attachments/tree.png)
"""
/// The attachment the rich card carries, so the card face shows a paperclip, the card element's
/// value says "1 attachment" (`AccessibilityPhrases.cardValue`), and the card window's sidebar
/// has a real attachment row to audit.
static let attachmentName = "notes.txt"
private static let attachmentBody = """
The audit fixture's attachment. Its only job is to exist, so the attachments section has a row.
"""
/// The card that is deleted into `.trash/`, named by `(lane, card)` index.
///
/// A trash with something in it is the only way the trash-shown audit reaches the elements
/// 10-accessibility.md specifies for it ordinary card elements whose context menu offers Delete
/// and Reveal in Finder and never Open. An empty column audits its own label and stops there.
static let trashedCardIndex = (lane: 0, card: 1)
// MARK: - Materialization
/// Builds the fixture board and answers its URL every write through `BoardWriter`, in the order
/// a user would have produced them.
///
/// Called from `RestoreBootstrapView` rather than from `KanbanApp.init()`: it is filesystem work,
/// and the launch path already has a place for filesystem work that has to happen before the
/// first real window (that view's whole reason for existing). A throw surfaces as a launch
/// failure on welcome the same treatment a board that fails to restore gets so a broken
/// fixture is visible rather than a suite that quietly audits an empty screen.
static func materializeFixtureBoard() throws -> URL {
let root = fixtureBoardURL
try BoardWriter.createBoard(at: root, title: boardTitle)
var laneURLs: [URL] = []
for title in laneTitles {
let id = try BoardWriter.createLane(inBoard: root, title: title)
laneURLs.append(root.appendingPathComponent(id.rawValue, isDirectory: true))
}
var cardURLs: [[URL]] = []
for (laneIndex, titles) in cardTitles.enumerated() {
var lane: [URL] = []
for title in titles {
let id = try BoardWriter.createCard(inLane: laneURLs[laneIndex], title: title)
lane.append(laneURLs[laneIndex].appendingPathComponent(id.rawValue, isDirectory: true))
}
cardURLs.append(lane)
}
let richCard = cardURLs[richCardIndex.lane][richCardIndex.card]
try BoardWriter.writeBody(inItemFolder: richCard, body: richCardBody)
try importFixtureAttachment(into: richCard)
// The delete goes last so the trashed card's identity is one the lanes above have already
// finished with and through the ordinary delete door, so `.trash/` ends up holding exactly
// what a user's would have put there, stamps and `order` included.
let doomed = cardURLs[trashedCardIndex.lane][trashedCardIndex.card]
try BoardWriter.deleteCardToTrash(
at: doomed,
inBoard: root,
order: Ranks.append(toVisible: [] as [Double])
)
return root
}
/// Writes the attachment's source into the scratch root and imports it the way a Finder drop
/// would (`BoardWriter.importAttachments`), so the card ends up with a real `attachments/`
/// folder rather than a hand-placed file the loader would have to normalize.
private static func importFixtureAttachment(into cardFolder: URL) throws {
let source = scratchRoot.appendingPathComponent(attachmentName, isDirectory: false)
try attachmentBody.write(to: source, atomically: true, encoding: .utf8)
_ = try BoardWriter.importAttachments([source], intoCard: cardFolder)
try? FileManager.default.removeItem(at: source)
}
}
+21 -12
View File
@@ -24,9 +24,10 @@ import SwiftUI
///
/// ### Which window appears at launch
///
/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived: the
/// preference is read before any scene exists, and `restorables()` costs a bookmark resolution per
/// known board a computed property here would pay that on every scene-graph evaluation.
/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived (see
/// `LaunchPlan`): the preference is read before any scene exists, and `restorables()` costs a
/// bookmark resolution per known board a computed property here would pay that on every
/// scene-graph evaluation.
@main
struct KanbanApp: App {
@@ -34,15 +35,23 @@ struct KanbanApp: App {
@State private var appModel: AppModel
/// Whether this launch restores boards: the preference is on **and** there is something flagged
/// to restore. Welcome "appears only when nothing restores".
private let shouldRestoreAtLaunch: Bool
/// What this launch does: welcome, the registry's restoration pass, or the accessibility audit
/// suite's fixture board (`LaunchPlan`, `UITestLaunch`).
private let launchPlan: LaunchPlan
init() {
let model = AppModel()
// Read first, because it decides *which registry file the model is built with* a fixture
// launch keeps its recents in the scratch directory rather than in the user's real one.
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
let registryStorageURL = isUITestFixtureLaunch
? UITestLaunch.prepareScratchDirectory()
: BoardRegistry.defaultStorageURL
let model = AppModel(registryStorageURL: registryStorageURL)
_appModel = State(initialValue: model)
shouldRestoreAtLaunch = AppModel.shouldRestoreAtLaunch(
preference: AppPreferences.restoreOpenBoardsAtLaunch,
launchPlan = LaunchPlan.decide(
isUITestFixtureLaunch: isUITestFixtureLaunch,
restorePreference: AppPreferences.restoreOpenBoardsAtLaunch,
hasRestorables: !model.boardRegistry.restorables().isEmpty
)
// The delegate is constructed by the adaptor before this runs, so this is the one place the
@@ -56,7 +65,7 @@ struct KanbanApp: App {
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
.defaultLaunchBehavior(launchPlan == .welcome ? .automatic : .suppressed)
.restorationBehavior(.disabled)
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
@@ -83,11 +92,11 @@ struct KanbanApp: App {
.commandsRemoved()
Window("", id: WindowID.restoreBootstrap) {
RestoreBootstrapView()
RestoreBootstrapView(plan: launchPlan)
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .presented : .suppressed)
.defaultLaunchBehavior(launchPlan.presentsBootstrap ? .presented : .suppressed)
.restorationBehavior(.disabled)
.windowStyle(.plain)
.defaultSize(width: 1, height: 1)