Actions is gone from the trailing sidebar. Its two rows land on the card
window's toolbar instead: Delete Card is a new default item (trash SF
Symbol), and Reveal in Finder joins the customizable catalog. The sidebar's
final order is now Style, Details, Attachments — no fourth section.
Delete Card is a push button gated by the same read-only predicate the
sidebar button carried, and it fires the identical write
(BoardStore.deleteCard(_:)) — same bracket, same stamps, no confirmation,
matching the delete flow exactly: recovery is the board's trash lane, so
there is nothing here for an alert to guard. It sits behind a trailing
flexibleSpace in the default set, apart from the four creation/view items
ahead of it, the HIG separation Mail.app's own toolbar Delete models —
one-click, no-confirm, recoverable by trash.
Reveal in Finder is catalog-only: it already had a menu-bar twin with no
default chord (File ▸ Reveal in Finder / RevealInFinderCommand), so nothing
was unreachable before this — the toolbar item is Customize's shortcut to
the same computation (CardAttachments.revealURLs), not a new path.
Delete Card needed a menu-row twin of its own before it could sit on the
toolbar at all ("toolbars are pure enhancement: every function they host
already has a menu item + shortcut" — 03-board-ui.md ▸ Toolbar). File ▸
Delete Card is that row: distinctly titled from the board-scope File ▸
Delete (whose title 11-command-nexus.md calls out as the ⌘⌫ chord's
singleton), and deliberately chord-less — an enabled delete-key equivalent
in the card window would steal delete-to-line-start from its text surfaces,
the same reason the board's own ⌘⌫ was never extended here in the first
place. A new small handle, CardWindowActions, carries the wiring through the
focus system the way CardAttachments and CardPrintSubject already do for
their own single-purpose seams — kept separate from CardAttachments on
purpose, since a delete has nothing to do with the attachments section that
type is scoped to.
CardToolbarTests grows the BoardToolbarTests split (defaults vs. catalog,
now that they differ) plus two new suites: Delete Card firing the wired
write under the lock, and Reveal in Finder's enablement mirroring the menu
row's own card-window computation.
05-card-window.md's Actions section and 11-command-nexus.md's File-menu
inventory are now stale; both amendments are owed and tracked on the card's
own thread rather than made here.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
395 lines
21 KiB
Swift
395 lines
21 KiB
Swift
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)
|
|
|
|
// **Beside Open…, not beside Export** (15-import-export.md): the two rows next to each
|
|
// other are the two ways a board arrives from disk, and like Open… this one needs no board
|
|
// in front — it makes one. Export lives further down with the board-scoped commands that
|
|
// act on the board already there. No default chord, like Welcome's row.
|
|
ImportBoardCommand(appModel: appModel)
|
|
|
|
OpenRecentMenu(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
BoardInfoCommand()
|
|
|
|
Divider()
|
|
|
|
DuplicateBoardCommand(appModel: appModel)
|
|
SaveAsTemplateCommand(appModel: appModel)
|
|
ShareBoardCommand(appModel: appModel)
|
|
// **Export ▸ joined 2026-08-09** (15-import-export.md) as the fourth row in a row that
|
|
// takes the whole board somewhere else — a sibling folder, the templates store, a share
|
|
// sheet, and now one foreign document. Three named rows rather than one row with a format
|
|
// popup; see `ExportBoardMenu` for why, and for why the titles matter.
|
|
ExportBoardMenu(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()
|
|
// **Delete Card joined 2026-08-09** (05-card-window.md ▸ Actions, retired — Pipeline card
|
|
// bcd3b323): the card window's own delete, promoted off the sidebar's Actions section to
|
|
// a menu row so the new toolbar item mirroring it (`CardToolbar`) has the menu twin every
|
|
// toolbar function needs. Distinctly titled from the row below — "Delete" is that row's
|
|
// own singleton title (11-command-nexus.md) — and deliberately chord-less: an enabled
|
|
// delete-key equivalent here would steal delete-to-line-start from this window's text
|
|
// surfaces, the reason `TrashCommands`' own ⌘⌫ was never extended to the card window.
|
|
DeleteCardCommand()
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|