From c5edcd8528809aa2cc4f73cfba3d8e948d94517f Mon Sep 17 00:00:00 2001 From: rzen Date: Wed, 29 Jul 2026 10:42:14 -0400 Subject: [PATCH] Run the accessibility verification pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppModel.swift | 6 +- Kanban/App/RestoreBootstrapView.swift | 49 ++- Kanban/App/UITestLaunch.swift | 314 ++++++++++++++++++++ Kanban/KanbanApp.swift | 33 +- KanbanTests/UITestLaunchTests.swift | 165 ++++++++++ KanbanUITests/AccessibilityAuditTests.swift | 264 ++++++++++++++++ KanbanUITests/AccessibilityVerification.md | 158 ++++++++++ README.md | 2 + project.yml | 18 +- 9 files changed, 989 insertions(+), 20 deletions(-) create mode 100644 Kanban/App/UITestLaunch.swift create mode 100644 KanbanTests/UITestLaunchTests.swift create mode 100644 KanbanUITests/AccessibilityAuditTests.swift create mode 100644 KanbanUITests/AccessibilityVerification.md diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 3d52b80..756c21c 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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 } diff --git a/Kanban/App/RestoreBootstrapView.swift b/Kanban/App/RestoreBootstrapView.swift index 2d977cd..d940451 100644 --- a/Kanban/App/RestoreBootstrapView.swift +++ b/Kanban/App/RestoreBootstrapView.swift @@ -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() + } } } diff --git a/Kanban/App/UITestLaunch.swift b/Kanban/App/UITestLaunch.swift new file mode 100644 index 0000000..2259037 --- /dev/null +++ b/Kanban/App/UITestLaunch.swift @@ -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) + } +} diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 8e211c7..a28b588 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -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) diff --git a/KanbanTests/UITestLaunchTests.swift b/KanbanTests/UITestLaunchTests.swift new file mode 100644 index 0000000..c1ebbb8 --- /dev/null +++ b/KanbanTests/UITestLaunchTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing +@testable import Kanban + +/// The launch decision and the audit suite's fixture board (10-accessibility.md ▸ Verification). +/// +/// Two halves, tested for two different reasons. `LaunchPlan.decide` and the flag predicate are +/// **pure**, so they are pinned here the way every other launch-time rule in this app is — without a +/// `UserDefaults` domain, a live registry, or a running app (`AppModel.shouldRestoreAtLaunch`'s own +/// argument, which this composes). +/// +/// The fixture board is tested here for a blunter reason: **the UI suite that consumes it cannot be +/// run in every environment** (it needs Accessibility automation permission and an unlocked +/// display), and a fixture that quietly failed to build would turn every audit into a pass over an +/// empty screen. Loading it back through the ordinary `BoardLoader` is the one check that runs +/// everywhere and would catch that. + +// MARK: - The launch plan + +@Suite("The launch plan") +struct LaunchPlanTests { + + /// The three-way decision, exhaustively — the two-way gate `AppModel.shouldRestoreAtLaunch` + /// already owns, plus the fixture's outright precedence over both halves of it. + /// + /// The precedence matters more than it looks: a UI-test launch that also restored the + /// developer's flagged boards would open real documents, run real watchers over them, and stamp + /// real registry records — during a test run whose whole premise is that nothing outside the + /// scratch directory is touched. + @Test( + "The fixture wins outright; otherwise the restore gate decides", + arguments: [ + (fixture: true, preference: true, restorables: true, expected: LaunchPlan.uiTestFixture), + (fixture: true, preference: false, restorables: false, expected: LaunchPlan.uiTestFixture), + (fixture: false, preference: true, restorables: true, expected: LaunchPlan.restoreBoards), + (fixture: false, preference: true, restorables: false, expected: LaunchPlan.welcome), + (fixture: false, preference: false, restorables: true, expected: LaunchPlan.welcome), + (fixture: false, preference: false, restorables: false, expected: LaunchPlan.welcome), + ] + ) + func decision(fixture: Bool, preference: Bool, restorables: Bool, expected: LaunchPlan) { + #expect( + LaunchPlan.decide( + isUITestFixtureLaunch: fixture, + restorePreference: preference, + hasRestorables: restorables + ) == expected + ) + } + + /// Which plans need the throwaway bootstrap window: both the ones that open something, neither + /// more. Welcome is a scene the app presents directly and needs no view to run a pass for it. + @Test("Only the two opening plans present the bootstrap window") + func bootstrapPresentation() { + #expect(LaunchPlan.welcome.presentsBootstrap == false) + #expect(LaunchPlan.restoreBoards.presentsBootstrap) + #expect(LaunchPlan.uiTestFixture.presentsBootstrap) + } +} + +// MARK: - The flag + +@Suite("The UI-test fixture flag") +struct UITestLaunchFlagTests { + + @Test("An exact occurrence anywhere in the argument list asks for the fixture") + func flagRecognized() { + #expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework", UITestLaunch.fixtureFlag])) + #expect(UITestLaunch.isFixtureLaunch(arguments: [UITestLaunch.fixtureFlag, "-NSTreatUnknownArgumentsAsOpen", "NO"])) + } + + /// An ordinary launch — including the one XCUITest performs with no arguments of its own — is + /// never a fixture launch. + @Test("An absent flag is an ordinary launch") + func flagAbsent() { + #expect(UITestLaunch.isFixtureLaunch(arguments: []) == false) + #expect(UITestLaunch.isFixtureLaunch(arguments: ["/path/to/Lanework"]) == false) + } + + /// **Exact match, not a prefix.** A launch switch with a fuzzy edge is a launch switch that can + /// be tripped by accident, and this one redirects the registry — the one place an accident would + /// look like the user's recents list having been wiped. + @Test("A near-miss is not the flag") + func flagNotMatchedLoosely() { + #expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)s"]) == false) + #expect(UITestLaunch.isFixtureLaunch(arguments: ["\(UITestLaunch.fixtureFlag)=basic"]) == false) + #expect(UITestLaunch.isFixtureLaunch(arguments: ["-ui-test-fixture-board"]) == false) + } + + /// The flag is double-dashed so `UserDefaults`' `NSArgumentDomain` — which reads `-key value` + /// pairs — never sees it as a preference. Stated as a test because the consequence of getting it + /// wrong is invisible: the app would work, and a stray defaults key would appear. + @Test("The flag cannot be read as an argument-domain preference key") + func flagIsNotAPreferenceKey() { + #expect(UITestLaunch.fixtureFlag.hasPrefix("--")) + } +} + +// MARK: - The fixture board + +@Suite("The audit fixture board") +struct UITestFixtureBoardTests { + + /// Builds the fixture exactly as a UI-test launch does, then reads it back through the ordinary + /// loader — the app's own answer to "is this a board", so the assertion is the same one the + /// board window would make. + /// + /// The scratch directory is prepared first (which is what wipes any previous run's board) and + /// removed afterwards, so this test leaves the container as it found it. + @Test("It builds, loads, and has the shape the audit suite navigates") + func fixtureLoads() throws { + UITestLaunch.prepareScratchDirectory() + defer { try? FileManager.default.removeItem(at: UITestLaunch.scratchRoot) } + + let root = try UITestLaunch.materializeFixtureBoard() + let model = try BoardLoader.load(boardRoot: root).model + + // The window title the UI suite waits on. + #expect(model.title.value == UITestLaunch.boardTitle) + #expect(root.lastPathComponent == "\(UITestLaunch.boardTitle).kanban") + + // Three lanes, in the order the fixture names them — which is also the order VoiceOver reads + // them in (10-accessibility.md ▸ Logical order), so a fixture whose lanes came out shuffled + // would make the traversal-order check meaningless. + #expect(model.lanes.map(\.title.value) == UITestLaunch.laneTitles) + + // The first lane is the crowded one, minus the card the fixture deleted — the masonry + // divergence the audit is most interested in needs more than one card to diverge. + #expect(model.lanes[0].cards.count == UITestLaunch.cardTitles[0].count - 1) + #expect(model.lanes[0].cards.allSatisfy { $0.title.value != UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card] }) + + // The trashed card is in the trash container and nowhere else — cards only, no lane entries + // (03-board-ui.md § Trash). + #expect(model.trash.count == 1) + #expect(model.trash.first?.title.value == UITestLaunch.cardTitles[0][UITestLaunch.trashedCardIndex.card]) + + // The rich card: the one the card-window audits open. Its window title is what the UI suite + // waits on, its body is what Preview renders into the tree, and its attachment is what the + // card element's value and the sidebar's row are made of. + let richLane = model.lanes[UITestLaunch.richCardIndex.lane] + let richCard = try #require(richLane.cards.first) + #expect(richCard.title.value == UITestLaunch.cardTitles[UITestLaunch.richCardIndex.lane][UITestLaunch.richCardIndex.card]) + #expect(richCard.body.contains("## What this card is for")) + #expect(richCard.attachments == [UITestLaunch.attachmentName]) + } + + /// Everything the fixture launch writes stays inside the app's own container — the sandbox + /// constraint that decided the whole design (a path handed over on the command line would not be + /// readable), stated as a test so a future "just use `/tmp`" cannot land quietly. + @Test("Everything it writes is inside the app container") + func scratchIsContained() { + let container = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).standardizedFileURL.path + #expect(UITestLaunch.scratchRoot.standardizedFileURL.path.hasPrefix(container)) + #expect(UITestLaunch.registryStorageURL.standardizedFileURL.path.hasPrefix(container)) + #expect(UITestLaunch.fixtureBoardURL.standardizedFileURL.path.hasPrefix(container)) + } + + /// The fixture registry is **not** the real one — the clause that keeps an audit run out of the + /// user's recents list. + @Test("The fixture registry is not the app's real registry") + @MainActor + func registryIsRedirected() { + #expect(UITestLaunch.registryStorageURL != BoardRegistry.defaultStorageURL) + } +} diff --git a/KanbanUITests/AccessibilityAuditTests.swift b/KanbanUITests/AccessibilityAuditTests.swift new file mode 100644 index 0000000..40ed402 --- /dev/null +++ b/KanbanUITests/AccessibilityAuditTests.swift @@ -0,0 +1,264 @@ +import XCTest + +/// **The automated half of 10-accessibility.md ▸ Verification**, whose first clause is the reason +/// this file is a set of tests and not a checklist: +/// +/// > **Automated audits are test failures**: Xcode's accessibility audit (`performAccessibilityAudit`) +/// > runs in UI tests over every surface — board (trash shown and hidden), card window (Preview, Edit, +/// > raw source), welcome, template chooser, board popover. +/// +/// Eight surfaces, eight tests, one audit call each. `performAccessibilityAudit` audits **the app's +/// currently displayed UI** rather than a subtree, so each test's job is entirely navigation: get the +/// surface on screen, then let the audit look at whatever is there. +/// +/// ### No waiving +/// +/// The audit's issue handler is where a false positive would be excused, and every test here passes +/// `nil` — no handler, nothing excused. That is the design's own posture ("violations are test +/// failures, not warnings"), and it is worth keeping literal: a handler that swallowed one issue type +/// app-wide would also swallow the next real one of that type, on a surface nobody was thinking about +/// when the waiver was written. Should a genuine platform false positive ever need excusing, it goes +/// in as a closure that matches **that one element on that one surface** and carries the reason in a +/// comment beside it — never a bare `return true`. +/// +/// ### `.all`, not a narrowed set +/// +/// `XCUIAccessibilityAuditType.all` is the default and stays the default. The narrower types +/// (`.contrast`, `.elementDetection`, `.hitRegion`, `.sufficientElementDescription`, `.textClipped`, +/// `.trait`) each map onto a rule 10-accessibility.md states — contrast is its ≥ 4.5:1 clause, +/// sufficient-description is its labels, trait is its selection and heading traits — so scoping any +/// of them out would be scoping out a design rule. They are named here only so a future narrowing has +/// to argue with this paragraph first. +/// +/// ### The fixture board +/// +/// Every test launches with `UITestLaunch.fixtureFlag`, which makes the app build a known board +/// inside its own container and open it (see `UITestLaunch` for why the board cannot simply be +/// handed over on the command line — the sandbox). The board is three lanes, six cards, one card +/// with a rich Markdown body and an attachment, and one card already in `.trash/`. +/// +/// ### Running these +/// +/// They drive the real app through the real menu bar, so the machine running them must have granted +/// the test runner Accessibility control (System Settings ▸ Privacy & Security ▸ Accessibility) and +/// must not be locked or headless. A run that cannot get automation permission fails on the first +/// `click()`, not on an accessibility defect — see `KanbanUITests/AccessibilityVerification.md`, +/// which puts this suite at the top of the manual pass for exactly that reason. +/// +/// ### Reading a failure +/// +/// The navigation waits are deliberately loud, and they are also the part most likely to need +/// adjusting: a window is identified by its **title** (`app.windows["…"]`, which is what +/// `navigationTitle` produces) and an on-board element by its **label**. Both are specified — +/// 11-command-nexus.md fixes the menu titles, `AccessibilityPhrases` fixes the labels — but neither +/// says which `XCUIElement.ElementType` SwiftUI will choose, and a hidden-title-bar window (welcome) +/// is the one place a title might not surface at all. So: a failure on `performAccessibilityAudit` +/// is an accessibility defect and is what this suite is for; a failure on a `waitForExistence` above +/// it is a navigation problem in *this file*, and the audit never ran. +final class AccessibilityAuditTests: XCTestCase { + + override func setUp() { + super.setUp() + // A failed navigation step makes every later step in that test meaningless — and an audit + // that ran against the wrong surface would report a *pass*, which is worse than a failure. + continueAfterFailure = false + } + + // MARK: - The board window + + /// The board as it opens: lanes, cards, the toolbar, the search field — trash hidden, which is + /// the board's default state (03-board-ui.md § Trash). + @MainActor + func testBoardWindowWithTrashHidden() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + try app.performAccessibilityAudit() + } + + /// The same board with View ▸ Show Trash on — "when shown, it is the last container, labeled as + /// Trash with its count" (10-accessibility.md ▸ Trash lane), holding the fixture's one trashed + /// card so the column's own card elements are audited and not just its header. + @MainActor + func testBoardWindowWithTrashShown() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + app.clickMenuItem("Show Trash", in: "View") + XCTAssertTrue( + app.element(labeled: "Trash").waitForExistence(timeout: XCUIApplication.uiTimeout), + "the trash column did not appear" + ) + try app.performAccessibilityAudit() + } + + // MARK: - The card window + + /// Preview mode — the card window's default. The fixture's rich card carries the structures 10 + /// makes claims about (headings, lists, a task list, a table, a code block, a link, an image with + /// alt text), so this is the audit of "Preview renders to the accessibility tree as structured + /// text" rather than of an empty body. + @MainActor + func testCardWindowPreviewMode() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + try app.openRichCardWindow() + try app.performAccessibilityAudit() + } + + /// Edit mode — View ▸ Edit Body (⌘E), "an ordinary accessible text editor" (10 ▸ Card window). + @MainActor + func testCardWindowEditMode() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + try app.openRichCardWindow() + app.clickMenuItem("Edit Body", in: "View") + try app.performAccessibilityAudit() + } + + /// The raw-source outlet — View ▸ Raw Source (⌥⌘E), the whole `index.md` as text. + /// + /// Entered from Preview rather than from Edit, because the two are mutually exclusive by design + /// ("Edit Body disables while Raw Source is active" — 05-card-window.md ▸ Raw source outlet) and + /// stacking them would be auditing a state the app does not have. + @MainActor + func testCardWindowRawSourceMode() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + try app.openRichCardWindow() + app.clickMenuItem("Raw Source", in: "View") + try app.performAccessibilityAudit() + } + + // MARK: - Welcome, the template chooser, the board popover + + /// The welcome window, reached by its own Window-menu row — and reached *after* the fixture board + /// has opened, so its recents list has a row in it. An empty welcome would audit the empty state + /// and miss the rows 10 specifies ("⟨name⟩, ⟨location⟩, N lanes, M cards"). + @MainActor + func testWelcomeWindow() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + app.clickMenuItem("Welcome to Lanework", in: "Window") + XCTAssertTrue( + app.windows["Welcome to Lanework"].waitForExistence(timeout: XCUIApplication.uiTimeout), + "the welcome window did not appear" + ) + try app.performAccessibilityAudit() + } + + /// The template chooser — File ▸ New Board… (⌥⌘N), the ten bundled templates as elements + /// "labeled by title", their mini previews hidden (10 ▸ Template chooser). + @MainActor + func testTemplateChooser() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + app.clickMenuItem("New Board…", in: "File") + XCTAssertTrue( + app.windows["New Board"].waitForExistence(timeout: XCUIApplication.uiTimeout), + "the template chooser did not appear" + ) + try app.performAccessibilityAudit() + } + + /// The board popover — File ▸ Board Info (⌘I): "labeled controls throughout", with the git slot's + /// information readable as text and never by colour or shape alone (10 ▸ Board popover). + @MainActor + func testBoardInfoPopover() throws { + let app = XCUIApplication.launchedWithFixtureBoard() + app.clickMenuItem("Board Info", in: "File") + XCTAssertTrue( + app.popovers.firstMatch.waitForExistence(timeout: XCUIApplication.uiTimeout), + "the board popover did not appear" + ) + try app.performAccessibilityAudit() + } +} + +// MARK: - Driving the app + +extension XCUIApplication { + + /// One timeout for the whole suite. Generous, because a cold launch here also builds a board on + /// disk, and because a UI test that is slow is not a UI test that is wrong. + static let uiTimeout: TimeInterval = 30 + + /// The first element anywhere in the app carrying `label`, whatever type it turned out to be. + /// + /// SwiftUI decides for itself which `XCUIElement.ElementType` a flattened accessibility element + /// lands as — a card face and a lane container are both "one element" by design and neither is + /// promised to be a `staticText` — so the waits in this file match on the label, which *is* + /// specified (`AccessibilityPhrases`), rather than on a type that is not. + @MainActor + func element(labeled label: String) -> XCUIElement { + descendants(matching: .any) + .matching(NSPredicate(format: "label == %@", label)) + .firstMatch + } + + /// Launches the app on the audit fixture board and waits for its window. + /// + /// The flag's spelling is `UITestLaunch.fixtureFlag` in the app target; it is written out here + /// because a UI test bundle does not link the app it drives — the two ends of a launch argument + /// are always a pair of literals, and this is the one place this end of the pair is spelled. + @MainActor + static func launchedWithFixtureBoard() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments += ["--ui-test-fixture-board"] + app.launch() + XCTAssertTrue( + app.wait(for: .runningForeground, timeout: uiTimeout), + "the app did not reach the foreground" + ) + // `UITestLaunch.boardTitle`, which is also the window title (`BoardWindowHost.windowTitle`). + // Waiting on *this* window rather than on `windows.firstMatch` is what makes a failed fixture + // build a failure here instead of an audit that quietly passed over the welcome window. + XCTAssertTrue( + app.windows["Audit Board"].waitForExistence(timeout: uiTimeout), + "the fixture board window did not open — check the app's launch log for a fixture build failure" + ) + return app + } + + /// Clicks a menu-bar row by its title. + /// + /// **By title, because the titles are API** (04-interactions.md ▸ Configurable bindings: menu + /// items are remapped by title through `NSUserKeyEquivalents`, so the strings in + /// `KanbanApp.menuCommands` are already a published contract). Driving the menus rather than + /// typing the chords also means these tests exercise the same path a keyboard user takes, and a + /// row that silently disabled itself fails here as an un-hittable element rather than as a + /// keystroke that went nowhere. + @MainActor + func clickMenuItem(_ title: String, in menu: String) { + let bar = menuBars.firstMatch + let menuBarItem = bar.menuBarItems[menu] + XCTAssertTrue( + menuBarItem.waitForExistence(timeout: Self.uiTimeout), + "the \(menu) menu is missing from the menu bar" + ) + menuBarItem.click() + + let item = bar.menuItems[title] + XCTAssertTrue( + item.waitForExistence(timeout: Self.uiTimeout), + "\(menu) ▸ \(title) is missing" + ) + XCTAssertTrue(item.isEnabled, "\(menu) ▸ \(title) is disabled") + item.click() + } + + /// Selects the fixture's rich card and opens its window. + /// + /// **The selection is seeded with one arrow press**, which is the app's own documented rule: + /// "an empty selection seeds at the first lane's first card" (04-interactions.md ▸ Grammar, + /// `BoardView.seed`). From there the walk to the rich card is ordinary arrow navigation — the + /// same keys a user without a pointer would press — so this helper needs to know no identifiers + /// and no geometry, only the fixture's shape. + /// + /// The rich card is the sole card of lane 2 ("Doing"), so: one press to seed into lane 1's first + /// card, then one `→` to step into lane 2. `→` steps to the nearest card in that direction, and + /// with one card in the lane there is only one it can be. + @MainActor + func openRichCardWindow() throws { + typeKey(.downArrow, modifierFlags: []) + typeKey(.rightArrow, modifierFlags: []) + clickMenuItem("Open Card", in: "Board") + // `UITestLaunch.cardTitles[1][0]`, which is the card window's title + // (`CardWindowHost.windowTitle`). + XCTAssertTrue( + windows["Write the smoke script"].waitForExistence(timeout: Self.uiTimeout), + "the card window did not open" + ) + } +} diff --git a/KanbanUITests/AccessibilityVerification.md b/KanbanUITests/AccessibilityVerification.md new file mode 100644 index 0000000..fd41502 --- /dev/null +++ b/KanbanUITests/AccessibilityVerification.md @@ -0,0 +1,158 @@ +# Accessibility verification + +The whole of DESIGN/10-accessibility.md § Verification, in one place: the automated audit suite, the manual VoiceOver smoke script, and the m11 checklist that the four implementation cards each left behind. Run it top to bottom. + +It lives beside `AccessibilityAuditTests.swift` because the design says it should — "a manual VoiceOver smoke script lives with the test plan" — and because the automated and manual halves are one pass, not two. + +## Contents + +1. [Before you start](#before-you-start) +2. [Part 1 — run the audit suite](#part-1--run-the-audit-suite) +3. [Part 2 — the VoiceOver smoke script](#part-2--the-voiceover-smoke-script) +4. [Part 3 — the m11 checklist](#part-3--the-m11-checklist) + - [3.1 The VoiceOver tree and its actions](#31-the-voiceover-tree-and-its-actions) + - [3.2 Live announcements](#32-live-announcements) + - [3.3 Text scaling, visual accommodations, Full Keyboard Access](#33-text-scaling-visual-accommodations-full-keyboard-access) + - [3.4 Runtime contrast and palette ink](#34-runtime-contrast-and-palette-ink) +5. [Recording the result](#recording-the-result) + +## Before you start + +Run the whole pass **per release**. Part 2 is the canonical "does the board actually work blind" check; nothing else in this repository substitutes for it. + +You need a real, unlocked display and a real keyboard — none of this survives a headless or screen-shared session. Grant the test runner Accessibility control under System Settings ▸ Privacy & Security ▸ Accessibility before Part 1, or every audit fails on its first click for a reason that has nothing to do with accessibility. + +VoiceOver's modifier is written **VO** throughout and is Control-Option by default. Turn VoiceOver on with ⌘F5. + +Have a scratch board to hand for Part 2 — a new one from File ▸ New Board… ▸ Basic is ideal, because the script creates and destroys everything it touches. + +## Part 1 — run the audit suite + +`KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names. **Violations are test failures, not warnings**, and nothing is waived: the audits pass no issue handler at all. + +``` +xcodebuild test -project Kanban.xcodeproj -scheme Kanban \ + -destination 'platform=macOS,arch=arm64' \ + -only-testing:KanbanUITests/AccessibilityAuditTests +``` + +The eight surfaces, and how each test gets there: + +| Test | Surface | Navigation | +| --- | --- | --- | +| `testBoardWindowWithTrashHidden` | Board window, default state | The fixture board opens at launch | +| `testBoardWindowWithTrashShown` | Board window, trash column shown | View ▸ Show Trash | +| `testCardWindowPreviewMode` | Card window, Preview | ↓ then → to select, Board ▸ Open Card | +| `testCardWindowEditMode` | Card window, Edit | …then View ▸ Edit Body | +| `testCardWindowRawSourceMode` | Card window, raw source outlet | …then View ▸ Raw Source | +| `testWelcomeWindow` | Welcome, with a recents row | Window ▸ Welcome to Lanework | +| `testTemplateChooser` | Template chooser | File ▸ New Board… | +| `testBoardInfoPopover` | Board popover | File ▸ Board Info | + +Every test launches the app with `--ui-test-fixture-board`, which makes the app build a known board inside its own container and open it — three lanes ("To Do", "Doing", "Done"), six cards, one card with a rich Markdown body and an attachment, one card already in the trash. The board and the registry both live in a scratch directory that is wiped on every launch, so an audit run never touches your real boards or your recents list. See `Kanban/App/UITestLaunch.swift` for why the board cannot simply be handed to the app on the command line (the sandbox). + +If a test fails, read the issue's `compactDescription` and fix the app. Adding a waiver is a design change and needs an entry on the Redesign board first. + +## Part 2 — the VoiceOver smoke script + +Nine steps, in one sitting, on a scratch board. Expected speech is quoted from `Kanban/UI/AccessibilityPhrases.swift`; where VoiceOver adds its own words (role names, "selected", "button") they are shown in [brackets]. + +Start with VoiceOver on (⌘F5), the scratch board frontmatter, and the trash hidden. + +**0. Land on the board.** Press VO-→ until the VoiceOver cursor reaches the lane strip. + +> Each lane reads **"To Do, lane, 2 cards"** — title, the word "lane", the plural-folded count. The count is the *filtered* count, the same number the visible badge shows. Interact with a lane (VO-⇧-↓) to reach its cards; each card is **one** element reading just its title — no separate icon, stripe, or chip stops. + +**1. Create a lane.** ⇧⌘N (File ▸ New Lane). + +> The new lane arrives with its title editor focused. Type `Later` and press Return. The lane now reads **"Later, lane, 0 cards"**. + +**2. Create a card.** With the new lane still active, ⌘N (File ▸ New Card). Alternatively VO-→ to the lane header's one child button, which reads **"New card in Later" [button]**, and VO-Space it. + +> The card placeholder appears with its editor focused. Type `Smoke test` and press Return. VO-→ onto it: **"Smoke test"**. No value is spoken — it has no attachments and is not cut. + +**3. Rename it.** With the card selected, open its context menu with VO-⇧-M and choose **Rename** — or use Board ▸ Rename. + +> The menu offers exactly **Open**, **Rename**, **Style…**, the quick-style row, **Delete**. Rename puts the caret in the title. Type `Smoke test, renamed` and press Return; the element now reads the new title. + +**4. Cut and paste it into another lane.** ⌘X, then arrow to a card in a different lane, then ⌘V. + +> On ⌘X the card's value gains **"cut, pending paste"** — the dim is the sighted signal, this is the other one, and hearing it is the point of the step. Move with plain arrows: the VoiceOver cursor and the app's selection are independent, so arrow to the destination and confirm the selection moved by listening for **[selected]** on the target. ⌘V lands the card **after** the anchor card; VO-→ over the destination lane and confirm the card is now in it, its "cut, pending paste" gone, and the source lane's spoken count has dropped by one. + +**5. Let an external edit land.** In a terminal, edit the board on disk — for example, append a line to a card's `index.md` body, or `mkdir` a new lane folder with an `index.md`. Wait for the reload debounce (about a second). + +> One polite, non-interrupting sentence, and only one: **"Board changed: 1 card edited"** — or whatever the digest counts, in the fixed order cards-before-lanes and edited/added/moved/deleted within each. A change no bucket counts (renaming the board) says just **"Board changed"**. Your own edits in the app must stay **silent**; if you hear a digest after clicking around in the app, that is a bug. +> +> Now delete, from the terminal, the very card the VoiceOver cursor is on. Expect **"Card 'Smoke test, renamed' was deleted externally"**, and focus recovering to that card's lane. Delete a whole lane the cursor is inside and expect **"Lane 'Later' was deleted externally, with 3 cards"** — the *lane* named, not a card — with focus landing on the lane that now occupies its position. + +**6. Delete a card.** Select a card and press ⌘⌫ (File ▸ Delete). + +> **No confirmation** — a board delete is recoverable. The card leaves the lane and the lane's spoken count drops. + +**7. Restore it from the trash.** View ▸ Show Trash, then arrow to the trashed card, ⌘X, arrow to a live lane, ⌘V. + +> Toggling the column announces its resulting state: **"Trash shown"** (and **"Trash hidden"** on the way back — the state, never the action). The column is the **last** container and reads **"Trash"** with its card count as its value. Its cards are ordinary card elements; VO-⇧-M on one offers exactly **Delete** and **Reveal in Finder** — **there is no Open**, and finding one is a defect. ⌘X/⌘V moves the card back onto the board; the trash's spoken count drops. + +**8. Empty the trash.** Delete another card, show the trash, then ⇧⌘⌫ (File ▸ Empty Trash…). + +> The alert always appears and is fully readable: **"Permanently delete 1 card?"** with **Delete** and **Cancel**. Confirm, and the column's value returns to **"0 cards"**. + +**9. Check the rotor.** Press VO-U and choose the Headings rotor, or press VO-⌘-H repeatedly. + +> Lane titles are headings, so heading navigation jumps lane to lane. On a one-dimensional board that *is* structural navigation. + +The script passes when every quoted sentence was heard, nothing was announced that shouldn't have been, and you never needed the pointer. + +## Part 3 — the m11 checklist + +The four implementation cards' manual items, consolidated. Each line is a claim the automated suite cannot make. + +### 3.1 The VoiceOver tree and its actions + +- [ ] **Traversal order is `order`, never geometry.** Widen a lane (⌥⌘→, or the header context menu's Increase Width) until its cards lay out in two or more interior masonry columns, then walk it with VO-→. The cards must read **top-to-bottom in card order**, not column-major. **This is the riskiest bet in the whole milestone** — it rests on `accessibilitySortPriority` being honoured on slots inside a custom `Layout` (`LaneView`'s masonry), which is not a documented guarantee. If it has regressed, this is where it shows. +- [ ] **Lanes read in lane order, and the trash reads last** — including with the trash shown, a lane lifted by an in-flight resize, and a right-to-left system language. +- [ ] **Lane containers**: "⟨title⟩, lane, N cards", with the count matching the visible badge. Type a query into the search field and confirm the *spoken* count drops with the badge — filtered-out cards leave the tree and the layout together. +- [ ] **Cards are one flattened element**: title as label; attachment count and "cut, pending paste" as value, in that order when both apply; face icon, edge stripe, and the paperclip chip never separately focusable. +- [ ] **VO-Space toggles selection, it does not replace it.** On a card and on a lane header alike, VO-Space is the ⌘-click analogue: pressing it on a second card must leave the first selected. Moving the VoiceOver cursor alone must never change the selection (Finder-style independence). +- [ ] **Selection is a trait, not just a ring** — VoiceOver says "selected"; the ring alone is not enough. +- [ ] **⌘↩ opens the card window** with VoiceOver running, and arrows and ⇧-arrows drive selection exactly as they do without it. +- [ ] **Context menus via VO-⇧-M** carry the full inventory, and the custom accessibility actions (VO-⌘-Space, or the Actions rotor) carry the same rows: card ▸ Open / Rename / Delete; lane ▸ Rename / Increase Width / Decrease Width / Delete; **trash card ▸ Delete / Reveal in Finder and never Open**. +- [ ] **The lane-resize drag strip is out of the tree.** The accessible width path is the stepper and the menu items; an invisible focusable strip between lanes is a defect. +- [ ] **Rotor headings** jump lane to lane (also checked in Part 2, step 9). + +### 3.2 Live announcements + +- [ ] **Foreign edits announce; app-mediated echoes never do.** Exercised in Part 2, step 5 — but also click around the app for a minute with VoiceOver on and confirm total silence. +- [ ] **One sentence per reload debounce**, never per file. Touch five files in one terminal command and expect a single digest, not five. +- [ ] **Vanishing focus is named specifically**, and lane-vanish walks up then sideways: focus lands on the lane now occupying the vanished lane's position — the next lane by order, else the previous — and on the board container only when no lanes remain. **Never into the trash**, even with the trash shown. +- [ ] **The banner strip is announced when it appears and when it clears.** Make the board unwritable (`chmod a-w` the board folder, or move it aside while it is open) and expect the error sentence; restore it and expect **"The board is editable again"**. A load breakage clearing says **"The board is loading again"**. +- [ ] **The banner row's spoken label is the banner's own sentence** — tone first, so a VoiceOver user hears *that* it is an error before hearing what it is: "Error: ⟨headline⟩". The strip's container reads as "Board status". +- [ ] **Announcements never interrupt.** Start VoiceOver reading a long card body (VO-A), then trigger a foreign edit; the digest must wait its turn rather than cutting the reading off. + +### 3.3 Text scaling, visual accommodations, Full Keyboard Access + +- [ ] **Largest system text size**: System Settings ▸ Accessibility ▸ Display ▸ Text size, at maximum. Card faces, lane headers, masonry spacing, the trash hatch, the toolbar search field and both window minimum sizes all grow with it; nothing clips, nothing overlaps. +- [ ] **No horizontal scroll at any text size.** The board still scrolls vertically only; lane widths are the user's choice and the strip never gains a horizontal scroller. Check at three sizes across two window widths. +- [ ] **Increase Contrast** (Accessibility ▸ Display ▸ Increase contrast): strokes gain a flat point, borderless card and lane plates gain a resting separator hairline, faded accents go to full alpha, and the selection ring and plate edge both strengthen. Hierarchy must be preserved — everything strengthening by the same amount is the intent, not a bug. +- [ ] **Reduce Transparency**: the transient search bar's glass goes solid, and so do the alpha washes that composite over a user-chosen board background — the trash plate, the hatched trash header, the drag shadow. Look for any remaining see-through surface. +- [ ] **Reduce Motion**: movement animations go instant, appear/disappear transitions go crossfade — uniformly. Named cases to walk: reflow-on-drag, search animate-out, the drag replica's lift and settle, the lane-resize rubber band, the trash column's appear/disappear, and the store's reload seam (the largest animated surface in the app). The drag replica's 1:1 tracking and the selection marquee correctly have no reduced variant. +- [ ] **Full Keyboard Access** (Keyboard ▸ Keyboard navigation, VoiceOver **off**): the board is **one** tab stop, not one per card, and its focus ring becomes visible under FKA when it is hidden without. Tab must reach every lane's new-card button, the toolbar, the search field, the board popover's controls, the card window, and welcome. +- [ ] **The template chooser is arrow-navigable** under FKA: tiles take focus, arrows move between them and clamp at the ends, Space picks, Return is still the sheet's default action, and focus follows the selection one way only. +- [ ] **The style editor's grids** are arrow-navigable, every well is Tab-reachable and labeled by name (palette color, symbol name; leading wells "None" / "Default"), the current value is stated by trait, and a batch selection's mixed state reads as "mixed". +- [ ] **State is never colour-alone**, everywhere: selection is ring plus trait, cut-pending is dim plus stated value, the trash header is hatched plus labeled. + +### 3.4 Runtime contrast and palette ink + +The only board text that sits on a user-chosen colour is the lane header (title, icon, badge, rename field, + button) and the trash header. Everything else has its own plate. Check both, in **both appearances**, switching System Settings ▸ Appearance between Light and Dark without restarting the app — the ink must flip live. + +- [ ] **Palette wells, both appearances.** Set the board background to **smokey-ocean** and view it in **Light Mode**; set it to **chalk** and view it in **Dark Mode**. These are the two that the pre-m11 code got wrong. Lane header text must stay legible in both; if it disappears into the background, the ink selection has regressed. +- [ ] **obsidian in Light Mode** — the specific case that measured 1.0:1 before this milestone. It must now read white-on-black, not black-on-black. +- [ ] **Hand-written hex.** Enter `#00000080` (the alpha dead zone) as the board background. The colour composites over the *window* background of the active appearance, so it resolves differently in Light and Dark — the text must be legible in both, and must change when you switch appearance with the popover still open. +- [ ] **A mid-grey hex** such as `#7F7F7F` cannot clear 4.5:1 against either label colour. The app must paint the **better** of the two rather than override the user's colour — legible-ish, never inverted, never refused. +- [ ] **Increase Contrast on top of a hex background**: the ink recomputes against the Increase Contrast label colours, not the default ones. +- [ ] **Card plates**: confirm the card face's `.background.secondary` plate is opaque enough that a saturated board background does not bleed through and drag the card's own text below AA. This one is a judgement call by eye — hold a dark card title against `#FFCC00` and against `obsidian`. +- [ ] **Menus, popovers, and drag replicas stay native** — they deliberately do not follow the board's ink, and should look like ordinary system chrome even over a strongly-coloured board. + +## Recording the result + +File anything that fails on the Redesign board, with the checklist line it came from. A failing line in Part 1 is a build defect; a failing line in Part 2 or 3 is either a build defect or a design gap, and which one it is belongs in the issue. diff --git a/README.md b/README.md index 6c1becd..d26e7d9 100644 --- a/README.md +++ b/README.md @@ -66,3 +66,5 @@ scripts/verify-editions.sh macOS 26+, Swift 6 (strict concurrency), SwiftUI, sandboxed. Internal codename `Kanban` (target, scheme, bundle id `dev.rzen.indie.Kanban`); the app ships under the display name **Lanework**. Two app targets are built from one source tree (DESIGN/12-editions.md): **Lanework** compiles `Kanban/` alone, **Lanework Pro** (target `KanbanPro`, scheme `LaneworkPro`, bundle id `dev.rzen.indie.KanbanPro`) compiles `Kanban/` plus the Pro-only source root `KanbanPro/`. There is no edition flag and no `#if` in shared code — an edition difference is a file one target builds and the other does not — and the difference is checkable on the signed products: base carries no libgit2 and no network-client entitlement, which `scripts/verify-editions.sh` asserts against the built bundles. The unit suite is edition-agnostic and runs twice, once hosted by each app (`KanbanTests`, `KanbanProTests` — the same sources, bound to the Pro module by `-module-alias`). Both editions declare the same `dev.rzen.indie.kanban-board` UTI, so any board opens in either app. + +**Accessibility is verified, not assumed** (DESIGN/10-accessibility.md § Verification). `KanbanUITests/AccessibilityAuditTests.swift` runs Xcode's accessibility audit over all eight surfaces the design names — the board with the trash shown and hidden, the card window in Preview, Edit and raw source, welcome, the template chooser, the board popover — and every violation is a test failure with nothing waived. Each test launches the app with `--ui-test-fixture-board`, a test-only argument that makes the app build a known three-lane board through its own `BoardWriter` inside its sandbox container, with its own scratch registry, so a run never touches real boards or real recents (`Kanban/App/UITestLaunch.swift`). The manual half — the per-release VoiceOver smoke script and the consolidated accessibility checklist — is `KanbanUITests/AccessibilityVerification.md`. Both halves need a real, unlocked display and Accessibility automation permission. diff --git a/project.yml b/project.yml index e2a8261..e4cd233 100644 --- a/project.yml +++ b/project.yml @@ -161,15 +161,25 @@ targets: # MARK: - UI tests # - # Base-only, deliberately: the single test here is `testAppLaunches`, and a second copy would - # double the slowest, most environment-dependent part of the suite to re-assert something the - # Pro unit bundle already proves (it launches the Pro app as its test host on every run). + # Base-only, deliberately: these tests launch the real app and drive its menu bar, which is the + # slowest, most environment-dependent part of the suite, and a second copy would double it to + # re-assert something the Pro unit bundle already proves (it launches the Pro app as its test host + # on every run). The accessibility audits (10-accessibility.md ▸ Verification) are edition-blind — + # every surface they walk is built from the shared tree. KanbanUITests: type: bundle.ui-testing platform: macOS sources: - - KanbanUITests + # The manual verification document lives with the tests it belongs to (10-accessibility.md + # ▸ Verification: "A manual VoiceOver smoke script lives with the test plan"), so it is listed + # in the project — visible where the suite is — but with no build phase: it is documentation, + # not a resource the bundle should carry. + - path: KanbanUITests + excludes: + - "**/*.md" + - path: KanbanUITests/AccessibilityVerification.md + buildPhase: none dependencies: - target: Kanban settings: