Build the menu bar per the Command Nexus

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
This commit is contained in:
2026-07-27 23:28:22 -04:00
parent cf87b72092
commit c1f304d3fe
5 changed files with 233 additions and 26 deletions
+15
View File
@@ -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 }
+130
View File
@@ -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")
}
}
+5 -1
View File
@@ -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)