Build the welcome screen

The welcome window becomes the real thing: Xcode-style, hidden title
bar with background drag, branding and actions left, recents right —
rows carrying the board symbol, name, location, and the registry's
cached lane/card counts (stamped at close, never a scan at welcome
time), sorted by last opened. Launch failures surface row-level per
02: a failure joins its recents row as a warning caption, an
unresolvable bookmark renders unavailable with Forget its one
affordance, and only a failure with no row to carry it falls back to
a compact list; a board opening again heals its row. New Board
(Opt-Cmd-N) opens the Pages-style template chooser — shipped with
the single Basic template and the m9 seams marked — flowing through
the save panel into createBoard/createLane and straight into a board
window. Open Recent gains its submenu with Clear Menu (byte-identical
to forgetting every row, pinned by test), and File > Duplicate forks
the frontmost board to a Finder-style copy sibling: pending work
flushes first through the close flush's step two alone (sessions stay
open — 09's stated exception), every GUID and tombstone carries (the
whole-board carve-out from copies-remint), and the copy opens in its
own window while the original stays put. 36 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 16:50:39 -04:00
parent 5ebf9fb90c
commit 4b97ecf3f0
18 changed files with 1904 additions and 73 deletions
+145 -7
View File
@@ -11,6 +11,11 @@ import os
public enum WindowID {
public static let welcome = "welcome"
public static let restoreBootstrap = "restore-bootstrap"
/// The template chooser (09-templates.md; File New Board N). Its own window rather than a
/// sheet on welcome because N is available *everywhere* (11-command-nexus.md) including from
/// a board window, and including when welcome is not open at all, which a sheet would have to
/// conjure a host for.
public static let templateChooser = "template-chooser"
public static let board = "board"
public static let card = "card"
}
@@ -66,11 +71,11 @@ public enum AppPreferences {
/// **A struct rather than the obvious tuple** only because SwiftUI needs identity to list these and
/// two failures can share a path (a board that failed, was retried, and failed again).
///
/// This is the minimum that satisfies "never a silent drop". The settled shape is richer 02
/// § Launch and window lifecycle wants the failure *on the board's recents row*, carrying fail-fast's
/// specifics or the unavailable state and that belongs with the recents list itself.
// m4-welcome: row-level failure rendering lands with the full welcome window (recents, Forget,
// Open Recent). Until then a plain list under the branding is the honest placeholder.
/// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` 02 § Launch and window
/// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
/// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
/// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
/// rather than derived from the message.
public struct LaunchFailure: Identifiable, Sendable, Equatable {
public let id = UUID()
public let path: String
@@ -229,10 +234,82 @@ public final class AppModel {
windowDismisser = dismiss
}
// MARK: Recents
/// The recents list, cached: what the welcome window renders and what File Open Recent lists
/// (02-architecture.md § Per-board app state "The recents list *is* this registry sorted by
/// last-opened").
///
/// **Cached rather than read through on demand, and both halves of that are deliberate.**
/// `BoardRegistry` is not `@Observable`, so a view reading it directly would never learn that a
/// row was forgotten; and `recents()` resolves every record's bookmark, which is filesystem work
/// no SwiftUI body should be doing on every evaluation the File menu's command graph is
/// rebuilt far more often than this list changes. So the list lives here as observable state and
/// every path that can change the registry refreshes it explicitly (`refreshRecents()`).
///
/// The honest residual: a registry mutated behind this object's back would show stale until the
/// next refresh. There is no such path today every writer goes through this type or through a
/// session it owns and welcome refreshes on appearance as the cheap belt-and-braces.
public private(set) var recents: [RecentBoard] = []
/// Re-reads the registry into `recents`. Called wherever the registry changes: a board opening,
/// a board closing (the counts are stamped there), Forget, Clear Menu, and welcome appearing.
public func refreshRecents() {
recents = boardRegistry.recents()
}
/// The welcome row's Forget (11-command-nexus.md Welcome recent) the record, plus any launch
/// failure that row was carrying, plus the refresh, in one call so no caller can do one without
/// the others.
///
/// **Forgetting the board forgets the failure too.** The row *is* the failure's surface (02
/// § Launch and window lifecycle); dropping the row while keeping the failure would relocate its
/// message into the unmatched-failures list, which reads as the app declining to forget.
public func forget(boardID: UUID) {
clearLaunchFailures(naming: knownPaths(ofBoard: boardID))
boardRegistry.forget(id: boardID)
refreshRecents()
}
/// File Open Recent Clear Menu (11-command-nexus.md).
///
/// **Finder clears the *menu*; here the registry is the menu**, so clearing removes every record
/// there is no second list to clear, and a "menu" that still knew about the boards it had
/// stopped listing would be a distinction with no surface. What that costs is per-board settings
/// (window frames, push-on-commit) for boards the user reopens later, which is exactly what
/// Forget costs one row at a time and what 02's "its settings are conveniences" already accepts.
///
/// It is Forget applied wholesale, so it clears failures the same way the ones naming records,
/// leaving a failure that named no row (and therefore no menu entry) standing in its own list.
///
/// A board that is open right now keeps working: its session holds a record id that no longer
/// resolves, and `BoardRegistry.update` treats an unknown id as a no-op for precisely this case.
public func clearRecents() {
clearLaunchFailures(naming: Set(recents.flatMap { recent in
[recent.record.lastKnownPath, recent.url?.path].compactMap { $0 }
}))
boardRegistry.forgetAll()
refreshRecents()
}
/// Every path a given record is known by the one it was last seen at and, when its bookmark
/// still resolves, where it lives now. The two can differ (a bookmark follows a move), and a
/// failure recorded before the move names the older one.
private func knownPaths(ofBoard id: UUID) -> Set<String> {
var paths: Set<String> = []
if let record = boardRegistry.record(id: id) {
paths.insert(record.lastKnownPath)
}
if let url = recents.first(where: { $0.record.id == id })?.url {
paths.insert(url.path)
}
return paths
}
// MARK: Launch failures
/// Boards that failed to restore or open, newest last the minimal welcome's one dynamic
/// section. See `LaunchFailure` for what replaces it.
/// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
/// one exists, and in a fallback list where none does `WelcomeRow.derive(recents:failures:)`.
public private(set) var launchFailures: [LaunchFailure] = []
// MARK: Card-window placement
@@ -266,6 +343,11 @@ public final class AppModel {
/// real Application Support directory".
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL)
// Read once here rather than lazily, so File Open Recent is populated from the app's first
// menu pass a launch that restores boards never shows welcome, and a submenu that filled
// in only after the first close would look broken. It costs one bookmark-resolution sweep at
// launch, next to the one `restorables()` already runs.
refreshRecents()
}
// MARK: - Opening
@@ -308,6 +390,14 @@ public final class AppModel {
windowOpener?(id: WindowID.welcome)
}
/// File New Board (N) shows, or focuses, the template chooser (09-templates.md).
///
/// The command opens a *chooser*, never a board: the location is the save panel's question and
/// the panel is the chooser's, so this method's whole job is the window.
public func showTemplateChooser() {
windowOpener?(id: WindowID.templateChooser)
}
/// The standard open panel behind File Open O (11-command-nexus.md).
///
/// **Validation is the open attempt itself** there is no pre-flight check that a folder is a
@@ -352,8 +442,17 @@ public final class AppModel {
}
/// Starts a board's session the board window's host calls this once its load has succeeded.
///
/// Two bookkeeping consequences of "this board is now open" ride along. The recents list is
/// re-read, because `recordOpen` just moved this board to the top of it. And any launch failure
/// naming this board is dropped: the board demonstrably opens, so a row still captioned with the
/// old error would be reporting a condition that has stopped being true. That is not the silent
/// drop 02 forbids it forbids a failure that was never surfaced disappearing, not one the user
/// has since fixed.
func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access)
clearLaunchFailures(naming: [ref.path, store.rootURL.path])
refreshRecents()
}
/// Registers a card window with its board's session, so the close flush can find it.
@@ -389,6 +488,22 @@ public final class AppModel {
launchFailures.removeAll()
}
/// Forgets exactly the named failures what the unmatched-failures list's Clear dismisses, so
/// that pressing it never also erases a message still standing on a recents row the user has
/// not looked at.
public func clearLaunchFailures(ids: Set<UUID>) {
launchFailures.removeAll { ids.contains($0.id) }
}
/// Drops every failure naming one of `paths` the resolution path, used when a board opens
/// successfully and when its record is forgotten. Paths are compared the way the welcome row's
/// join compares them, so "this row's failure" means the same thing in both places.
private func clearLaunchFailures(naming paths: Set<String>) {
guard !paths.isEmpty else { return }
let keys = Set(paths.map(WelcomeRow.pathKey))
launchFailures.removeAll { keys.contains(WelcomeRow.pathKey($0.path)) }
}
// MARK: - Counts
/// The lane and card counts stamped into the registry at close **live items only** (02
@@ -442,6 +557,29 @@ public final class AppModel {
defer { closingBoards.remove(ref) }
await coordinator(for: ref).run(cause: cause)
// The flush stamped this board's counts and (on a user close) cleared its open-now flag, so
// the cached list is now one close out of date and welcome is often the very next thing on
// screen.
refreshRecents()
}
/// The close flush's **pending-work step, without the teardown** what File Duplicate runs
/// before it copies (03-board-ui.md § Welcome screen & templates: "The copy is preceded by the
/// close flush ... so neither the tree nor the copied history misses pending work").
///
/// **Not `closeBoard`**, and the design says so itself: 09-templates.md Save as Template states
/// the rule together with its exception "with the pull-style mechanical exception committing an
/// open Edit session's on-disk saves as-is, **sessions staying open**". A duplicate leaves the
/// original on screen (03: "the original stays open too"), so what it needs is pending work
/// *landed on disk*, not a session ended: no card window is dismissed, no record is stamped
/// closed, nothing is torn down, and the board the user is looking at never blinks.
///
/// It goes through `CloseFlushCoordinator` rather than calling the store directly so that the
/// order of the three flushes store pipeline, then editor saves, then the pending auto-commit
/// (02's own order) keeps having exactly one definition.
public func flushPendingWork(for ref: BoardWindowRef) async {
guard sessions[ref] != nil else { return }
await coordinator(for: ref).flushPendingWork()
}
/// Quit: the same sequence, once per open board, **sequentially**.