import AppKit import IndieAbout 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() { // **AppKit window restoration is fully disowned** — restore-at-launch is the registry's job // (02-architecture.md § Launch and window lifecycle), every scene below declares // `.restorationBehavior(.disabled)`, and left alive the machinery is actively harmful: AppKit // counts its saved state (even a windowless one) as "a restored session", and SwiftUI then // treats every `defaultLaunchBehavior` as moot — the app launches with no windows at all and // no way to get one, since `windowOpener` is captured by the first scene that appears // (observed on macOS 26, 2026-07-29; `-ApplePersistenceIgnoreState YES` on the command line // proved the mechanism). Registered here because `App.init` runs before `NSApplicationMain`, // which is what makes a registration-domain default early enough for AppKit's read. UserDefaults.standard.register(defaults: ["ApplePersistenceIgnoreState": true]) // Read first, because it decides *which app-side state the model is built over* — a fixture // launch keeps its recents and its clipboard snapshots in the scratch directory rather than in // the app's ordinary Application Support home. let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch if isUITestFixtureLaunch { UITestLaunch.prepareScratchDirectory() } let model = AppModel( registryStorageURL: isUITestFixtureLaunch ? UITestLaunch.registryStorageURL : BoardRegistry.defaultStorageURL, clipboardStagingRoot: isUITestFixtureLaunch ? UITestLaunch.clipboardStagingRoot : ClipboardStore.defaultStagingRoot ) _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) } // **Never presented by the system** — the restore bootstrap below opens welcome through // `AppModel.showWelcome()` when the launch pass ends with nothing else on screen. `.automatic` // was tried here (conditioned on the plan) and macOS 26 answered it by presenting *no scene at // all*: not welcome, not even a `.presented` bootstrap — a windowless launch with no way back, // since `windowOpener` is captured by the first scene that appears. One presenter, one rule. .defaultLaunchBehavior(.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) } // **Presented at every launch, whatever the plan** — the bootstrap is the app's one reliable // way to put a window on screen. Welcome's `.automatic` above is a request the system is free // to decline, and on macOS 26 it does: a launch with nothing to restore presented *no* scene // at all, which left `windowOpener` uncaptured and the app a windowless shell no menu action // could revive (observed 2026-07-29). The pass itself // still dispatches on the plan — a `.welcome` launch restores nothing and shows welcome — // and this window stays invisible and dismisses itself either way. .defaultLaunchBehavior(.presented) .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 About window (11-command-nexus.md files About under the app menu's standard // furniture): icon, version/build/date from the Info.plist that `update_build_info.sh` // stamped at build time — never a hardcoded string — with the version line opening the // bundled changelog. `AboutBox` names no edition (12-editions.md ▸ PIVOT 2026-08-08) — // there is one version of this app, so there is one box. IndieAboutCommand(configuration: AboutBox.configuration) // 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, Share…, 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. // // **Share… joined 2026-08-09** (design ruling, card 72691b11) right after Save as // Template: the third command in a row that copies the whole board wholesale, each to a // different destination — a sibling folder, the templates store, or a share sheet. 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) ShareBoardCommand(appModel: appModel) RevealInFinderCommand() AddAttachmentCommand() // The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's // context-menu contract requires ("no function's only home"). Two rows rather than one // that renames itself, for titles-are-API's reason (`SetAsHeroCommand`). SetAsHeroCommand() RemoveHeroCommand() AddCommentCommand() Divider() TrashCommands() } // Edit ▸ Undo/Redo, replacing the system's own pair — **the command surface is the app's** // (13-native-undo.md ▸ Rules, re-ruled 2026-08-08): the platform's nil-target rows resolve // through `NSWindow.undoManager`, which a SwiftUI window latches empty before any delegate of // ours can vend the board's, so the rows below read the focused session's stack themselves // (`UndoCommands.swift`). UndoRedoCommands() // The Edit menu: Paste as Board Background, then Find (⌘F) and its card-window stepping // twins, placed after the standard Cut/Copy/Paste/Select All group, which is where macOS puts // Find. The clipboard items above them stay the system's, answered by the board as a // responder (`ClipboardCommands.swift`) — a second item sharing one of those titles is what // titles-are-API forbids. Undo and Redo were the system's too until the latch (the group just // above). // // **Paste as Board Background joined 2026-08-09** with the image-data paste branch: it sits // directly under the system's Paste because it is the one paste this app has that ⌘V could // not carry — a backdrop has no selection to target, so it needs a name rather than a // modifier (`PasteBoardBackgroundCommand`). No default chord, like Welcome's row. CommandGroup(after: .pasteboard) { PasteBoardBackgroundCommand(clipboard: appModel.clipboard) Divider() 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. Three dividers split it // by scope, which is the only grouping the inventory implies: the board's toggle, then the // board's zoom ladder, then the card window's view-state rows, then the app-wide appearance // override — last, because unlike everything above it, it needs no window in front at all. // // "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window // menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a // second one would collide (the rule this file's own header states). CommandGroup(after: .toolbar) { ShowTrashCommand() Divider() ZoomCommands(appModel: appModel) Divider() CardViewCommands() Divider() AppearanceCommands(appModel: appModel) } // The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open // Card, Copy Link, Rename, Style…, the card moves, the lane moves, the width 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. // // **Board Settings… came out 2026-08-07** with the sheet it opened (03 ▸ Board settings // sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured // in its popover, whose keyboard door is File ▸ Board Info ⌘I, so this menu's last divider // went with the row. // // **The Board ▸ Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git // itself (strategy/01-git-excision.md): the width pair is now the menu's last row. // // **Copy Link joined 2026-08-09** (design ruling, card 737a949f) right beside Open Card: the // two read-only rows — nothing here mutates the board — grouped ahead of the edit-shaped // block below (Rename, Style…), which is where the context menu puts the same two. CommandMenu("Board") { OpenCardCommand() CopyLinkCommand() BoardRenameCommand() BoardStyleCommand() Divider() MoveCardCommands() MoveLaneCommands() Divider() LaneWidthCommands() } 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() } } // File ▸ Print… (⌘P) — **the app's own row**, replacing the system's nil-target one // (11-command-nexus.md's Print row; the "No Print story in v1 (⌘P unused)" line it retired). // // `replacing: .printItem` rather than an addition, for the same reason Undo/Redo replace the // platform's pair: the standard rows are nil-target actions resolved through the responder // chain, and this app's print target is the *frontmatter-shaped document behind the focused // window*, which no responder vends. Two items sharing the title "Print…" is also exactly what // titles-are-API forbids. // // A reported "This application does not support printing" alert (2026-08-09) turned out to be // the sandbox denying `NSPrintOperation` for want of `com.apple.security.print` // (`Kanban.entitlements`) — the actual fix, not anything here. Found alongside it, and worth // keeping regardless: `PrintCommand` used to disable itself over a window with no board and no // card, and a disabled `Button` still owns its `.keyboardShortcut` — the unclaimed chord fell // straight through to AppKit's own nil-target `printDocument:`, whose stock failure happens to be // the identical alert text by a different route. The row now claims ⌘P unconditionally and // answers the no-board-no-card case itself (`PrintCommand`'s doc comment). // // Page Setup… stays absent with it: the paper questions are answered in the print panel's own // page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one // margin. CommandGroup(replacing: .printItem) { PrintCommand(appModel: appModel) } // 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) } } } }