Stand up the window architecture — welcome, board, card

Four scenes (welcome, restore bootstrap, board group, card group) with
system restoration disabled in favor of the registry's open-now flags:
set when a window actually opens, cleared only on user close, so quit —
and crash — leave exactly the restoration set behind. AppModel joins
windows to sessions (shared store, registry record, card refs, held
security scope); CloseFlushCoordinator pins 02's strict close order as
a seam-injected machine (card sessions end, windows drain, store
flushes, record stamps, teardown) with named slots where m6/m7 flushes
land. HostedWindowController proxies — never replaces — SwiftUI's
window delegate to intercept windowShouldClose for the flush, report
frames, and place saved frames onto live screens. Card windows are
(board path, case-folded card id) values: reopen focuses, and a
snapshot-pure fate function dismisses on delete, tombstone, tombstoned
lane, or cross-board move.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 07:47:11 -04:00
parent 61e18c3dfa
commit fccdf56cf4
19 changed files with 2767 additions and 6 deletions
+117 -4
View File
@@ -1,12 +1,125 @@
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: 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
/// Whether this launch restores boards: the preference is on **and** there is something flagged
/// to restore. Welcome "appears only when nothing restores".
private let shouldRestoreAtLaunch: Bool
init() {
let model = AppModel()
_appModel = State(initialValue: model)
shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch
&& !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 {
WindowGroup {
Text("Lanework")
.font(.largeTitle)
.padding(80)
Window("Welcome to Lanework", id: WindowID.welcome) {
WelcomeView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
.restorationBehavior(.disabled)
.windowResizability(.contentSize)
// 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()
Window("", id: WindowID.restoreBootstrap) {
RestoreBootstrapView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .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)
Settings {
SettingsView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
}
/// The two menu items this milestone owns.
///
/// **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.
@CommandsBuilder
private var menuCommands: some Commands {
CommandGroup(after: .newItem) {
Button("Open…") {
appModel.presentOpenPanel()
}
.keyboardShortcut("o", modifiers: .command)
}
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()
}
}
}
}