From c1f304d3fe2e29d0417a36beb4f8f957641d5e14 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 27 Jul 2026 23:28:22 -0400 Subject: [PATCH] Build the menu bar per the Command Nexus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nexus parity, audited row by row (11-command-nexus.md § Menu commands) — every already-built item's title, chord, and placement matched exactly; this pass fills what remained: - The future-window rows, present with stable titles and validation-driven disablement until their milestones fill the actions: Save as Template (m9), Add Attachment… ⇧⌘A, Find Next/Previous ⌘G/⇧⌘G, the View-menu card triplet Edit Body ⌘E / Raw Source ⌥⌘E / History (m6), Board ▸ Pull/Push (git milestones) — one shared disabled-row shape in FutureCommands.swift so later milestones only flip validation. - No Print story in v1: the print group is removed. - Help carries the Nexus's one remap-teaching line — Customize Keyboard Shortcuts…, opening System Settings' Keyboard ▸ Shortcuts extension directly (the modern extension URL, verified to launch the appex). - The launch-restore decision now runs through a pure, tested AppModel.shouldRestoreAtLaunch gate; the Settings pane's caption rides a proper Form section footer. 904 unit tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Kanban/App/AppModel.swift | 15 ++++ Kanban/App/FutureCommands.swift | 130 ++++++++++++++++++++++++++++++++ Kanban/App/WelcomeView.swift | 6 +- Kanban/KanbanApp.swift | 88 +++++++++++++++------ KanbanTests/AppModelTests.swift | 20 +++++ 5 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 Kanban/App/FutureCommands.swift diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index bd198e6..f823404 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -374,6 +374,21 @@ public final class AppModel { refreshRecents() } + // MARK: - Launch restoration + + /// The launch-restoration gate, as a pure function (02-architecture.md § Launch and window + /// lifecycle: "the preference gates only whether the flagged set is consulted; the flags are + /// maintained regardless"). + /// + /// `KanbanApp.init()` is where this actually runs — read once, before any scene exists, into a + /// `let` rather than a computed property, because `restorables()` costs a bookmark resolution per + /// 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 { + preference && hasRestorables + } + // MARK: - Opening public var hasOpenBoards: Bool { !sessions.isEmpty } diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift new file mode 100644 index 0000000..bd971d1 --- /dev/null +++ b/Kanban/App/FutureCommands.swift @@ -0,0 +1,130 @@ +import SwiftUI + +// MARK: - The shared shape + +/// The shape behind every menu row this milestone ships **before** the window that answers it. +/// +/// 11-command-nexus.md's own contract runs both directions: "a command absent here doesn't exist, and +/// adding one means adding a row here first" — so once a row *is* in the Nexus, shipping the window +/// behind it is a validation-and-action change, not a menu change. `FutureCommand` (a `Button`) and +/// `FutureToggleCommand` (a `Toggle`) below are that reading, applied: the row exists now, stably +/// titled and stably chorded — `NSUserKeyEquivalents` already resolves it, so a user can remap it +/// today — with validation pinned to `false` and the action a no-op until the milestone named at the +/// call site fills both in. That milestone's whole diff then reads as "flip `.disabled`, fill the +/// closure" rather than "add a menu item", which is also why every call site below carries the +/// codebase's `mN-` marker for a component still owed. +/// +/// **The title never moves once a row ships**, disabled or not: a toggle wired live later must not +/// gain a second spelling on the way (04-interactions.md ▸ Configurable bindings — "toggles keep one +/// stable title, checkmark state only" — which applies to a row that has not started ticking yet +/// exactly as it does to one that has). +struct FutureCommand: View { + let title: String + var key: KeyEquivalent? + var modifiers: EventModifiers = .command + + var body: some View { + Button(title) { + // No-op: the milestone named at the call site wires this in. + } + .keyboardShortcut(key.map { KeyboardShortcut($0, modifiers: modifiers) }) + .disabled(true) + } +} + +/// `FutureCommand`'s checkmark-state twin, for a row the Nexus already marks "(checkmark toggle)". +/// +/// `isOn` is a constant `false` rather than real state: there is no session yet for a binding to +/// read, which is exactly the disabled, unchecked state a not-yet-wired toggle should show. +struct FutureToggleCommand: View { + let title: String + var key: KeyEquivalent? + var modifiers: EventModifiers = .command + + var body: some View { + Toggle(title, isOn: .constant(false)) + .keyboardShortcut(key.map { KeyboardShortcut($0, modifiers: modifiers) }) + .disabled(true) + } +} + +// MARK: - File ▸ Save as Template + +/// File ▸ Save as Template — no default chord (11-command-nexus.md; 09-templates.md). +/// +// m9-templates: copies the open board into the user templates store, close-flushed first exactly as +// Duplicate is (09 ▸ Save as Template: "The copy is preceded by the close flush"), `.git` stripped, +// tombstones dropped, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's +// one carve-out from the read-only lock — live under the unwritable-location state unless an open +// Edit/raw-source session holds unsaved content — so it cannot simply borrow +// `DuplicateBoardCommand`'s predicate outright. +struct SaveAsTemplateCommand: View { + var body: some View { + FutureCommand(title: "Save as Template") + } +} + +// MARK: - File ▸ Add Attachment… + +/// File ▸ Add Attachment… (⇧⌘A) — card window only (11-command-nexus.md). +/// +// m6-card-window: the menu-bar twin of the attachments section's quiet add affordance +// (05-card-window.md § Attachments) and of a whole-window Finder file drop. Validation will be scope +// alone — a card window in front, the read-only lock aside — `BoardInfoCommand`'s shape for its own +// scope-only item. +struct AddAttachmentCommand: View { + var body: some View { + FutureCommand(title: "Add Attachment…", key: "a", modifiers: [.shift, .command]) + } +} + +// MARK: - Edit ▸ Find Next / Find Previous + +/// Edit ▸ Find Next / Find Previous (⌘G / ⇧⌘G) — the card window's find bar stepping, +/// "disabled in the board window — board search is a live filter, not a cursor" +/// (11-command-nexus.md). +/// +// m6-card-window: joins `FindCommand` in the Edit menu once the card window's find-in-text exists +// (05-card-window.md). Both rows are unconditionally disabled here rather than reading `boardStore` +// to prove "board window" disables them: there is no card-window find session anywhere yet for +// either validation branch to check. +struct FindSteppingCommands: View { + var body: some View { + FutureCommand(title: "Find Next", key: "g", modifiers: .command) + FutureCommand(title: "Find Previous", key: "g", modifiers: [.shift, .command]) + } +} + +// MARK: - View ▸ Edit Body / Raw Source / History + +/// View ▸ Edit Body (⌘E) / Raw Source (⌥⌘E) / History — the card window's three view-state rows +/// (11-command-nexus.md). +/// +// m6-card-window: Edit Body and Raw Source are checkmark toggles reading the window's edit-mode +// state ("Edit Body disables while Raw Source is active" — 05-card-window.md); History is a plain +// command that focuses the sidebar's History section and disables outright on mode `none` / +// repo-nested boards once that section exists (05-card-window.md, 07-sync-collab.md). All three are +// unconditionally disabled here — there is no card-window mode state anywhere yet. +struct CardViewCommands: View { + var body: some View { + FutureToggleCommand(title: "Edit Body", key: "e", modifiers: .command) + FutureToggleCommand(title: "Raw Source", key: "e", modifiers: [.option, .command]) + FutureCommand(title: "History") + } +} + +// MARK: - Board ▸ Pull / Push + +/// Board ▸ Pull / Push — no default chord, remote-backed boards only (11-command-nexus.md; +/// 07-sync-collab.md: "also Board-menu items"). +/// +// m7-git: menu-bar twins of the board popover's own Pull/Push buttons (07-sync-collab.md). +// Validation will be remote-mode plus 06's abnormal-state pause ("disabled during 06's +// abnormal-state pause ... and on an unresolvable remote" — 11-command-nexus.md). Unconditionally +// disabled here: there is no remote model, no popover twin, and no git mode to validate against yet. +struct RemoteCommands: View { + var body: some View { + FutureCommand(title: "Pull") + FutureCommand(title: "Push") + } +} diff --git a/Kanban/App/WelcomeView.swift b/Kanban/App/WelcomeView.swift index 60d9302..97cb8cc 100644 --- a/Kanban/App/WelcomeView.swift +++ b/Kanban/App/WelcomeView.swift @@ -358,7 +358,11 @@ struct SettingsView: View { var body: some View { Form { - Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch) + Section { + Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch) + } footer: { + Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.") + } } .formStyle(.grouped) .frame(width: 420) diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 9731131..74798f9 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle). @@ -40,8 +41,10 @@ struct KanbanApp: App { init() { let model = AppModel() _appModel = State(initialValue: model) - shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch - && !model.boardRegistry.restorables().isEmpty + shouldRestoreAtLaunch = AppModel.shouldRestoreAtLaunch( + preference: 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 @@ -118,20 +121,24 @@ struct KanbanApp: App { } } - /// The menu items the app has built so far. + /// 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 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, and - /// changing one silently breaks every remap of it. + /// 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, still owed — 09), - // Reveal in Finder, 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. + // 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) @@ -152,37 +159,42 @@ struct KanbanApp: App { Divider() DuplicateBoardCommand(appModel: appModel) + SaveAsTemplateCommand() RevealInFinderCommand() + AddAttachmentCommand() Divider() TrashCommands() } - // The Edit menu's one row of ours: Find (⌘F), 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. - // - // m6-card-window: Find Next / Find Previous (⌘G/⇧⌘G) join here, scoped to the card window's - // find bar and "disabled in the board window — board search is a live filter, not a cursor" - // (11-command-nexus.md). They wait for the window that owns them rather than shipping as two - // permanently disabled rows. + // 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 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, alongside the card - // window's Edit Body / Raw Source / History still to come. + // 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. 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. + // 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() @@ -196,6 +208,10 @@ struct KanbanApp: App { Divider() LaneWidthCommands() + + Divider() + + RemoteCommands() } CommandGroup(after: .windowList) { @@ -205,5 +221,27 @@ struct KanbanApp: App { 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) + } + } } } diff --git a/KanbanTests/AppModelTests.swift b/KanbanTests/AppModelTests.swift index 06f951f..9783b3a 100644 --- a/KanbanTests/AppModelTests.swift +++ b/KanbanTests/AppModelTests.swift @@ -300,4 +300,24 @@ struct AppModelTests { model.storeRegistry.release(try #require(model.session(for: ref)?.store)) } + + // MARK: Launch restoration + + /// App ▸ Settings…'s "Restore open boards at launch" (11-command-nexus.md) gates the flagged set + /// by AND, not by either half alone: the preference off never restores even with boards flagged + /// (a user who turned it off gets welcome, full stop), and the preference on restores nothing when + /// there is nothing flagged (an ordinary first launch, which shows welcome exactly as it always + /// has, not an empty restoration pass). + @Test( + "The launch-restoration gate is the preference AND something to restore", + arguments: [ + (preference: true, hasRestorables: true, expected: true), + (preference: true, hasRestorables: false, expected: false), + (preference: false, hasRestorables: true, expected: false), + (preference: false, hasRestorables: false, expected: false), + ] + ) + func launchRestorationGate(preference: Bool, hasRestorables: Bool, expected: Bool) { + #expect(AppModel.shouldRestoreAtLaunch(preference: preference, hasRestorables: hasRestorables) == expected) + } }