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: