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
270 lines
12 KiB
Swift
270 lines
12 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
|
|
///
|
|
/// ### Four scenes, and why each is the kind it is
|
|
///
|
|
/// - **Welcome** is a `Window`: there is one of it, ever, and `openWindow(id:)` focuses the existing
|
|
/// one rather than making a second.
|
|
/// - **The restore bootstrap** is a `Window` too, and a deliberate oddity — see
|
|
/// `RestoreBootstrapView` for why launch-time work has to wear a window at all.
|
|
/// - **Boards** and **cards** are `WindowGroup(for:)`s, because their identity is a *value*: opening
|
|
/// with a ref that already has a window focuses it, which is how "one board window per root" and
|
|
/// "at most one card window per card (reopen focuses)" are enforced by the scene rather than by
|
|
/// bookkeeping.
|
|
///
|
|
/// ### Restoration is the registry's, not the system's
|
|
///
|
|
/// Both groups declare `.restorationBehavior(.disabled)`. The app already knows which boards were
|
|
/// open — the registry's open-now flags, which survive a crash and reopen in `lastOpened` order —
|
|
/// and letting AppKit *also* restore windows would produce duplicates, and worse, card windows
|
|
/// restored behind boards that never opened. One mechanism, and it is the one that can explain
|
|
/// itself when a board has moved or gone.
|
|
///
|
|
/// ### Which window appears at launch
|
|
///
|
|
/// 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 {
|
|
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
@State private var appModel: AppModel
|
|
|
|
/// 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() {
|
|
// 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)
|
|
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
|
|
// app's model and its AppKit half meet.
|
|
appDelegate.appModel = model
|
|
}
|
|
|
|
var body: some Scene {
|
|
Window("Welcome to Lanework", id: WindowID.welcome) {
|
|
WelcomeView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.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
|
|
// *minimum* and the recents list is meant to take whatever space the user gives it; the
|
|
// hidden title bar is why `WelcomeView` carries a `WindowDragGesture`.
|
|
.windowResizability(.contentMinSize)
|
|
.defaultSize(width: 820, height: 500)
|
|
.windowStyle(.hiddenTitleBar)
|
|
// Its automatic Window-menu item is replaced by the explicit command below, so the title is
|
|
// the one 11-command-nexus.md names rather than whatever the scene happens to be called.
|
|
.commandsRemoved()
|
|
|
|
// The template chooser (09-templates.md), reached from File ▸ New Board… ⌥⌘N and from
|
|
// welcome's own button. A `Window` for welcome's reason — there is one of it, ever — and
|
|
// suppressed at launch, since ⌥⌘N is the only thing that ever asks for it.
|
|
Window("New Board", id: WindowID.templateChooser) {
|
|
TemplateChooserView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.restorationBehavior(.disabled)
|
|
.windowResizability(.contentSize)
|
|
.commandsRemoved()
|
|
|
|
Window("", id: WindowID.restoreBootstrap) {
|
|
RestoreBootstrapView(plan: launchPlan)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(launchPlan.presentsBootstrap ? .presented : .suppressed)
|
|
.restorationBehavior(.disabled)
|
|
.windowStyle(.plain)
|
|
.defaultSize(width: 1, height: 1)
|
|
.commandsRemoved()
|
|
|
|
WindowGroup(id: WindowID.board, for: BoardWindowRef.self) { $ref in
|
|
if let ref {
|
|
BoardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.commands { menuCommands }
|
|
|
|
WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in
|
|
if let ref {
|
|
CardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
// The **first** card window's size, and only that one: every later window opens at the
|
|
// last-used size or at its card's remembered frame, both applied by the host as the window
|
|
// attaches (05-card-window.md ▸ Window). Derived from font metrics like every other
|
|
// measurement in that window rather than written down in points.
|
|
.defaultSize(CardWindowMetrics.defaultSize(bodyPointSize: CardWindowMetrics.bodyPointSize))
|
|
|
|
Settings {
|
|
SettingsView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
|
|
/// Every menu command 11-command-nexus.md inventories, at full row parity: built and validated
|
|
/// where the window behind it already exists, present-but-`FutureCommand`-disabled where it
|
|
/// doesn't (`FutureCommands.swift`).
|
|
///
|
|
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
|
/// mechanism — `NSUserKeyEquivalents` under the hood — remaps menu items *by title*, so these
|
|
/// strings are the keys a user's custom binding is stored under. They are spelled exactly as
|
|
/// 11-command-nexus.md inventories them, unique across the whole menu bar, and changing one
|
|
/// silently breaks every remap of it. Nothing below builds that mechanism — a stable title is the
|
|
/// whole of the contract, and the system supplies the rest.
|
|
@CommandsBuilder
|
|
private var menuCommands: some Commands {
|
|
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
|
// Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Reveal in Finder, Add
|
|
// Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate against
|
|
// the frontmost board through the focus system, so each is simply absent-of-effect when no
|
|
// board is in front; New Board… and Open Recent are available everywhere, including with no
|
|
// window at all. Close is the system's own item — no row of ours to add.
|
|
CommandGroup(after: .newItem) {
|
|
BoardCreationCommands()
|
|
NewBoardCommand(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
Button("Open…") {
|
|
appModel.presentOpenPanel()
|
|
}
|
|
.keyboardShortcut("o", modifiers: .command)
|
|
|
|
OpenRecentMenu(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
BoardInfoCommand()
|
|
|
|
Divider()
|
|
|
|
DuplicateBoardCommand(appModel: appModel)
|
|
SaveAsTemplateCommand(appModel: appModel)
|
|
RevealInFinderCommand()
|
|
AddAttachmentCommand()
|
|
|
|
Divider()
|
|
|
|
TrashCommands()
|
|
}
|
|
|
|
// The Edit menu: Find (⌘F), then its card-window stepping twins, placed after the standard
|
|
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. Undo/Redo and the
|
|
// clipboard items are the system's and the board answers them as a responder
|
|
// (`ClipboardCommands.swift`) — a second item sharing one of those titles is what
|
|
// titles-are-API forbids.
|
|
CommandGroup(after: .pasteboard) {
|
|
FindCommand()
|
|
FindSteppingCommands()
|
|
}
|
|
|
|
// The system's own toolbar rows — Show/Hide Toolbar and **Customize Toolbar…**, which
|
|
// 11-command-nexus.md files under Standard macOS furniture ("Customize Toolbar… per system
|
|
// convention (03)"). They are the platform's, spelled by the platform: both are nil-target
|
|
// AppKit actions the key window's toolbar answers, so they validate per window (disabled on
|
|
// welcome, live on the board and card windows) with nothing of ours in between. The
|
|
// right-click ▸ Customize Toolbar… path 03 names is AppKit's too, and needs no row at all.
|
|
ToolbarCommands()
|
|
|
|
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
|
// items live in — which is where 11-command-nexus.md files Show Trash. A divider separates it
|
|
// from the card window's three view-state rows below: one board-scoped toggle, then a
|
|
// card-scoped trio.
|
|
CommandGroup(after: .toolbar) {
|
|
ShowTrashCommand()
|
|
|
|
Divider()
|
|
|
|
CardViewCommands()
|
|
}
|
|
|
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
|
// Card, Rename, Style…, the card moves, the lane moves, the width pair, then the remote pair.
|
|
// Its items act on the frontmost board window, which they reach through the focus system
|
|
// rather than through the app model — see `BoardCommands.swift`, which also owns their
|
|
// validation.
|
|
CommandMenu("Board") {
|
|
OpenCardCommand()
|
|
BoardRenameCommand()
|
|
BoardStyleCommand()
|
|
|
|
Divider()
|
|
|
|
MoveCardCommands()
|
|
MoveLaneCommands()
|
|
|
|
Divider()
|
|
|
|
LaneWidthCommands()
|
|
|
|
Divider()
|
|
|
|
RemoteCommands()
|
|
}
|
|
|
|
CommandGroup(after: .windowList) {
|
|
// No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps
|
|
// like any other item.
|
|
Button("Welcome to Lanework") {
|
|
appModel.showWelcome()
|
|
}
|
|
}
|
|
|
|
// "No Print story in v1 (⌘P unused)" (11-command-nexus.md ▸ Standard macOS furniture) — the
|
|
// system's default Print item is removed outright rather than left dead, since a menu item
|
|
// with nothing behind it is exactly what the Nexus's "a command absent here doesn't exist"
|
|
// rules out in the other direction too.
|
|
CommandGroup(replacing: .printItem) {}
|
|
|
|
// Help carries "the one line teaching the System Settings remap path" (11-command-nexus.md ▸
|
|
// Standard macOS furniture) — this app's whole Help menu, since there is no other content to
|
|
// give it. The pane identifier is the modern System Settings extension id, not the legacy
|
|
// `com.apple.preference.keyboard` prefpane path: verified on macOS 26 by observing
|
|
// `KeyboardSettings.appex` (service `com.apple.Keyboard-Settings.extension`) launch in
|
|
// response to this exact URL. `?Shortcuts` is as deep as a URL reaches; **App Shortcuts**
|
|
// itself is one more click, the sidebar row inside that pane.
|
|
CommandGroup(after: .help) {
|
|
Button("Customize Keyboard Shortcuts…") {
|
|
guard let url = URL(
|
|
string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension?Shortcuts"
|
|
) else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
}
|