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
+212
View File
@@ -0,0 +1,212 @@
import AppKit
import SwiftUI
import os
// MARK: - Focused values
/// The frontmost board window's **identity**, published beside its store by `BoardWindowHost`.
///
/// `FocusedBoardStoreKey` answers "which board is in front"; this answers "which *window*", which is
/// a different question and the one File Duplicate has to ask: the flush that precedes a copy is
/// keyed on the window's session, not on the store (`AppModel.flushPendingWork(for:)`).
struct FocusedBoardWindowRefKey: FocusedValueKey {
typealias Value = BoardWindowRef
}
/// The welcome window's selected recents row File Reveal in Finder's welcome scope.
struct FocusedWelcomeSelectionKey: FocusedValueKey {
typealias Value = WelcomeRow
}
extension FocusedValues {
var boardWindowRef: BoardWindowRef? {
get { self[FocusedBoardWindowRefKey.self] }
set { self[FocusedBoardWindowRefKey.self] = newValue }
}
var welcomeSelection: WelcomeRow? {
get { self[FocusedWelcomeSelectionKey.self] }
set { self[FocusedWelcomeSelectionKey.self] = newValue }
}
}
// MARK: - New Board
/// File New Board (N) the template chooser's entry point (11-command-nexus.md;
/// 09-templates.md).
///
/// **N, not N**: N is New *Card*, which is the command a board window user reaches for a hundred
/// times a day, so the rarer creation wears the modifier. Available everywhere a new board needs
/// no board in front, and the welcome window's own button is this item's twin.
struct NewBoardCommand: View {
let appModel: AppModel
var body: some View {
Button("New Board…") {
appModel.showTemplateChooser()
}
.keyboardShortcut("n", modifiers: [.option, .command])
}
}
// MARK: - Open Recent
/// File Open Recent (11-command-nexus.md: "Everywhere; reads the board registry").
///
/// The registry, rendered as a menu same rows as the welcome list, through the same derivation, so
/// the two can never disagree about a board's name or about whether it can be opened. An
/// unavailable board is **listed and disabled** rather than hidden, which is the recents row's own
/// posture (02 § Graceful orphaning) applied to a menu: a board that has gone missing is information,
/// and a menu that quietly shortened itself would be the app forgetting on the user's behalf.
///
/// Clear Menu sits at the bottom, where Finder puts it. See `AppModel.clearRecents` for the
/// equivalence it rests on the registry *is* this menu, so clearing the menu clears the registry.
struct OpenRecentMenu: View {
let appModel: AppModel
/// The failures are deliberately not joined in here: a menu item has no room for fail-fast's
/// specifics, and a board that failed to open is still a board the user may want to try again.
/// The failure's surface is the welcome row (02 § Launch and window lifecycle).
private var rows: [WelcomeRow] {
WelcomeRow.derive(recents: appModel.recents, failures: []).rows
}
var body: some View {
let rows = self.rows
Menu("Open Recent") {
ForEach(rows) { row in
Button(row.displayName) {
guard let url = row.url else { return }
appModel.openBoard(at: url)
}
.disabled(!row.canOpen)
}
if !rows.isEmpty {
Divider()
}
Button("Clear Menu") {
appModel.clearRecents()
}
.disabled(rows.isEmpty)
}
}
}
// MARK: - Duplicate
/// File Duplicate (S) **the board**, never the selection (11-command-nexus.md, 03-board-ui.md
/// § Welcome screen & templates).
///
/// ### What it does, in the order 03 fixes
///
/// 1. **The flush first** "The copy is preceded by the close flush ... so neither the tree nor the
/// copied history misses pending work". Not a *close*: 09-templates.md states the rule with its
/// exception attached ("sessions staying open"), and 03 is explicit that "the original stays open
/// too". `AppModel.flushPendingWork(for:)` is that step of the sequence, run on its own.
/// 2. **The copy** `BoardDuplicator`, off the main actor so the spinner can spin.
/// 3. **The copy opens in its own board window** "macOS Duplicate convention" through the
/// ordinary open path, so it registers, bookmarks, and titles itself like any other board.
///
/// ### Validation
///
/// Board window only, so a welcome-selected recent can never be duplicated by accident 03 says it
/// "never acts on a welcome-selected recent", and scoping the item to the focused board window is
/// how that is enforced rather than remembered.
///
/// **Disabled under the read-only lock in every state** (03: "the flush can't run and the sibling
/// destination shares the board's fate"). It uses `acceptsBoardMutations`, which adds the
/// focused-inline-editor half of 04's rule to the lock 03 names a deliberate reading rather than a
/// slip: an open title editor holds the one pending change no flush can reach, and a duplicate taken
/// mid-rename would be a fork missing the edit the user is in the middle of making.
struct DuplicateBoardCommand: View {
let appModel: AppModel
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardWindowRef) private var ref
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "duplicate")
var body: some View {
Button("Duplicate") {
duplicate()
}
.keyboardShortcut("s", modifiers: [.shift, .command])
.disabled(!canDuplicate)
}
private var canDuplicate: Bool {
guard let store, ref != nil else { return false }
return store.acceptsBoardMutations
}
private func duplicate() {
guard canDuplicate, let store, let ref else { return }
let name = AppModel.displayName(of: store)
let source = store.rootURL
Task { @MainActor in
// The in-progress row 02 § The banner surface names for "big-board Duplicate": info
// tone, pinned, cleared on completion, swapped for the error row on failure.
//
// No Cancel yet. 02 promises one on copy-shaped work ("remove the partial copy, nothing
// lost"), which needs a cooperatively cancellable copy and a cleanup of the partial
// destination; `beginOperation`'s `cancel` slot is where it plugs in.
let operation = store.banners.beginOperation(label: "Duplicating '\(name)'…")
defer { store.banners.endOperation(operation) }
await appModel.flushPendingWork(for: ref)
do {
// Off the main actor: the copy is real I/O on a board that may carry a large `.git`,
// and a spinner drawn by a blocked main thread is a still picture. See
// `BoardDuplicator` for why that is safe here.
let copy = try await Task.detached(priority: .userInitiated) {
try BoardDuplicator.duplicate(boardAt: source, titled: name)
}.value
appModel.openBoard(at: copy)
} catch let error as BoardWriteError {
Self.logger.error("duplicate failed: \(error.description, privacy: .public)")
store.banners.post(error)
} catch {
store.banners.post(BoardWriteError(
operation: .duplicateBoard(title: name),
path: source.path,
reason: .io(message: error.localizedDescription)
))
}
}
}
}
// MARK: - Reveal in Finder
/// File Reveal in Finder **the welcome scope** (11-command-nexus.md: "welcome: the selected
/// recent's folder (disabled on unavailable rows) the context-menu entry's required twin").
///
/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's
/// *required* twin, so shipping one without the other would leave the context menu as the only path
/// to a command the thing 04's contract forbids.
///
// m5-context-menus, m6-card-window: the item's other two scopes. Board window the selection's
// folder(s), or the board root with nothing selected arrives with the board's own context menus;
// card window the card's folder, or the selected attachment's file when the attachments section is
// focused with the card window. Each adds a focused value and a branch here; the welcome branch
// does not move.
struct RevealInFinderCommand: View {
@FocusedValue(\.welcomeSelection) private var selection
var body: some View {
Button("Reveal in Finder") {
guard let url = selection?.url else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
}
.disabled(selection?.canReveal != true)
}
}
+145 -7
View File
@@ -11,6 +11,11 @@ import os
public enum WindowID { public enum WindowID {
public static let welcome = "welcome" public static let welcome = "welcome"
public static let restoreBootstrap = "restore-bootstrap" 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 board = "board"
public static let card = "card" 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 /// **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). /// 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 /// The join onto a recents row is `WelcomeRow.derive(recents:failures:)` 02 § Launch and window
/// § Launch and window lifecycle wants the failure *on the board's recents row*, carrying fail-fast's /// lifecycle wants the failure *on the board's row*, carrying fail-fast's specifics or the
/// specifics or the unavailable state and that belongs with the recents list itself. /// unavailable state, and a failure naming no row (a first open of a folder that was never a board)
// m4-welcome: row-level failure rendering lands with the full welcome window (recents, Forget, /// falls back to a list of its own. `path` is what the join matches on, which is why it is stored
// Open Recent). Until then a plain list under the branding is the honest placeholder. /// rather than derived from the message.
public struct LaunchFailure: Identifiable, Sendable, Equatable { public struct LaunchFailure: Identifiable, Sendable, Equatable {
public let id = UUID() public let id = UUID()
public let path: String public let path: String
@@ -229,10 +234,82 @@ public final class AppModel {
windowDismisser = dismiss 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 // MARK: Launch failures
/// Boards that failed to restore or open, newest last the minimal welcome's one dynamic /// Boards that failed to restore or open, newest last. Rendered on their own recents rows where
/// section. See `LaunchFailure` for what replaces it. /// one exists, and in a fallback list where none does `WelcomeRow.derive(recents:failures:)`.
public private(set) var launchFailures: [LaunchFailure] = [] public private(set) var launchFailures: [LaunchFailure] = []
// MARK: Card-window placement // MARK: Card-window placement
@@ -266,6 +343,11 @@ public final class AppModel {
/// real Application Support directory". /// real Application Support directory".
public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) { public init(registryStorageURL: URL = BoardRegistry.defaultStorageURL) {
boardRegistry = BoardRegistry(storageURL: registryStorageURL) 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 // MARK: - Opening
@@ -308,6 +390,14 @@ public final class AppModel {
windowOpener?(id: WindowID.welcome) 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). /// 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 /// **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. /// 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?) { func beginSession(ref: BoardWindowRef, store: BoardStore, recordID: UUID, access: ScopedAccess?) {
sessions[ref] = BoardSession(store: store, recordID: recordID, cardRefs: [], access: access) 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. /// 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() 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 // MARK: - Counts
/// The lane and card counts stamped into the registry at close **live items only** (02 /// 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) } defer { closingBoards.remove(ref) }
await coordinator(for: ref).run(cause: cause) 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**. /// Quit: the same sequence, once per open board, **sequentially**.
+96
View File
@@ -0,0 +1,96 @@
import Foundation
/// File Duplicate (S) the copy itself (03-board-ui.md § Welcome screen & templates).
///
/// ### A literal tree copy, and every one of its exclusions is deliberate
///
/// - **Every GUID is kept.** A whole-board copy is 01-storage-format.md's explicit carve-out from
/// the copies-remint rule: "the remint rule governs *item-level* copies landing inside an existing
/// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's
/// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it
/// describes)".
/// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included
/// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This
/// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy
/// carries it by declining to be clever.
/// - **`.git` comes along** a duplicate of a git board is a fork of its history with only its
/// remote configuration stripped, which is m7's.
/// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason.
/// **Nothing here reads a board file at all.**
///
/// `FileManager.copyItem` rather than a walk through `BoardWriter.copyItem`: the Writer's copy path
/// exists to remint identities and restamp frontmatter at an import boundary, and this operation is
/// defined by doing neither of those things.
///
/// ### Not `@MainActor`
///
/// Duplicating a board with a year of `.git` behind it is real I/O, and it runs while the original's
/// window stays open with an in-progress banner row spinning (02-architecture.md § The banner
/// surface names "big-board Duplicate" as an example). A spinner on a blocked main thread is a
/// frozen picture, so the caller runs this off the main actor. That is safe by construction: it
/// touches only its two URLs, and the board's security-scoped access is a process-wide grant the
/// session holds open for the window's whole life, not a per-thread one.
enum BoardDuplicator {
/// The Finder-style destination for duplicating `rootURL`: `"Board copy"`, then `"Board copy 2"`,
/// `"Board copy 3"`, Finder's own ladder, counting up from 2 against what is on disk at
/// decision time, one collision at a time.
///
/// **A sibling**, per 03 ("a Finder-style 'copy' sibling"), so a board found in a folder full of
/// boards produces its duplicate where the user is already looking.
///
/// The extension rides on the end (`Board.kanban` `Board copy.kanban`) and an extension-less
/// board folder simply has none to carry (`Board` `Board copy`) both are shapes a board is
/// allowed to be (01-storage-format.md § Document packaging, "Extension-less board folders still
/// open"), and both are what `URL`'s own splitting produces, which is also how the Writer's
/// attachment-collision helper spells the same idea.
///
/// `fileExists` is the one test, and it is true for a file as much as a folder: anything already
/// wearing the name blocks it, which is what keeps a duplicate from ever overwriting something.
static func copyDestination(for rootURL: URL) -> URL {
let parent = rootURL.deletingLastPathComponent()
let base = rootURL.deletingPathExtension().lastPathComponent
let ext = rootURL.pathExtension
func candidate(_ name: String) -> URL {
parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true)
}
var name = "\(base) copy"
var counter = 2
while FileManager.default.fileExists(atPath: candidate(name).path) {
name = "\(base) copy \(counter)"
counter += 1
}
return candidate(name)
}
/// Copies the board at `rootURL` to its Finder-style sibling and answers where it landed.
///
/// `title` is the board's display name, carried only so a failure can name the board the user
/// pressed Duplicate on this function never reads it off disk, which is the whole point.
///
/// The failure is a `BoardWriteError` like every other write in the app, so the board window's
/// banner renders it in the vocabulary it already speaks. A copy that fails part-way leaves a
/// partial folder behind; `FileManager` cleans up its own destination on most failures, and the
/// residue that survives is a folder the user can see and delete the honest outcome, and the
/// one 02's "remove the partial copy" Cancel affordance would formalise when it lands.
static func duplicate(boardAt rootURL: URL, titled title: String?) throws(BoardWriteError) -> URL {
let destination = copyDestination(for: rootURL)
do {
try FileManager.default.copyItem(at: rootURL, to: destination)
} catch {
throw BoardWriteError(
operation: .duplicateBoard(title: title),
path: destination.path,
reason: .io(message: error.localizedDescription)
)
}
// m7-git: strip the copy's remote configuration "the duplicate keeps `.git` but has its
// remote configuration stripped ... it must not silently push into the original's remote"
// (03-board-ui.md). Remotes only: the repo-local `user.name`/`user.email` survives, so the
// fork keeps its commit identity (06-history-undo.md's identity home). Push-on-commit needs
// nothing here it lives on the registry record, and the copy's record is born fresh.
return destination
}
}
+89
View File
@@ -0,0 +1,89 @@
import Foundation
/// A board template what File New Board (N) instantiates (09-templates.md).
///
/// ### One template today, and that is the shape of this card, not a shortcut
///
/// 09 settles both the inventory (all ten pathfinder templates carry over) and the definition
/// format, and the format is the interesting part: **a template is itself a board** a schema-valid
/// board folder in the app's resources, read by the same `BoardLoader`, its `index.md` supplying the
/// display name (`title`), the picker blurb (the body), the icon, and the chooser position
/// (`template.order`). None of that exists yet. What this card ships is the *entry point*: the
/// chooser window, the save panel, and a real path from N to an open board, with exactly one
/// template behind it so that path is exercised rather than described.
///
// m9-templates: the inventory becomes a walk of `<bundle>/Templates/*.kanban` plus the user store in
// Application Support, each folder loaded through `BoardLoader` `name`/`blurb`/`icon` off the
// template board's own `index.md`, order off its `template.order`, an unloadable user template still
// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable.
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips tombstones,
// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never
// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from
// these strings.
struct BoardTemplate: Identifiable, Sendable, Equatable {
/// The bundle folder name a real template would have (`basic.kanban` `basic`) 09 calls it
/// "the template's stable slug (tests, a11y ids)", so it is the identity here too.
let slug: String
/// The chooser's display name a real template's `title`.
let name: String
/// The chooser's blurb a real template's `index.md` body, which also becomes the new board's
/// description. Nothing is written from it yet: this card creates lanes, not board bodies.
let blurb: String
/// The board icon shown in the picker and inherited by the new board.
let icon: String
/// The lanes to create, in order.
let laneTitles: [String]
var id: String { slug }
/// The plain scaffold, and the one template that exists.
///
// m9-templates: the bundled `basic.kanban` is 09's "plain To Do / Done scaffold" two lanes,
// not these three. The third is here because a chooser preview with two lanes reads as a mistake
// and because this stub's whole job is to prove the create path; when the bundled template
// arrives it replaces this value wholesale and 09's inventory is the only source.
static let basic = BoardTemplate(
slug: "basic",
name: "Basic",
blurb: "Three lanes to move work through.",
icon: ItemSymbol.board,
laneTitles: ["To Do", "Doing", "Done"]
)
/// Every template the chooser offers, in chooser order.
static let all: [BoardTemplate] = [.basic]
// MARK: - Instantiation
/// Writes this template to `rootURL`: the board's `index.md`, then one lane per title, in order.
///
/// **The board's title is the document name the user chose**, not the template's 09
/// Instantiation says so, and 01-storage-format.md § Board naming is the reason: display name
/// and folder name start out matching, so a board called "Roadmap" on disk is called "Roadmap" in
/// its window title. An extension-less name is as legal a board as a `.kanban` one, so the
/// extension is stripped rather than required.
///
/// Lanes land at `1024`, `2048`, `3072` without this function saying so: each `createLane` call
/// appends after the visible siblings the previous one left behind (`Ranks.append(toVisible:)`),
/// which is what makes the array's order the board's order.
///
/// Separated from the panel and from the window flow deliberately this is the whole of what
/// "instantiate a template" means on disk, and a test drives it against a temp folder without
/// going anywhere near `NSSavePanel`.
func instantiate(at rootURL: URL) throws(BoardWriteError) {
try BoardWriter.createBoard(at: rootURL, title: Self.documentName(of: rootURL))
for title in laneTitles {
_ = try BoardWriter.createLane(inBoard: rootURL, title: title)
}
}
/// The document name behind a chosen URL `~/Boards/Roadmap.kanban` `Roadmap`.
static func documentName(of rootURL: URL) -> String {
rootURL.deletingPathExtension().lastPathComponent
}
}
+3
View File
@@ -95,6 +95,9 @@ struct BoardWindowHost: View {
// beside it the window's own popover flag, which is what File Board Info toggles, and // beside it the window's own popover flag, which is what File Board Info toggles, and
// its purge-alert host, which the trash's two confirmed commands raise. // its purge-alert host, which the trash's two confirmed commands raise.
.focusedSceneValue(\.boardStore, store) .focusedSceneValue(\.boardStore, store)
// The window's identity beside its store File Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing.
.focusedSceneValue(\.boardWindowRef, ref)
.focusedSceneValue(\.boardInfo, boardInfo) .focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.trashConfirmations, trashConfirmations) .focusedSceneValue(\.trashConfirmations, trashConfirmations)
} }
+7 -1
View File
@@ -198,7 +198,13 @@ public struct CloseFlushCoordinator {
/// Step 2. The store's pipeline, then the editor saves, then the pending commit 02's order, /// Step 2. The store's pipeline, then the editor saves, then the pending commit 02's order,
/// stated once. /// stated once.
private func flushPendingWork() async { ///
/// **Callable on its own**, which is the one place the sequence is entered part-way: File
/// Duplicate needs pending work on disk before it copies but must leave the session standing
/// (09-templates.md Save as Template's stated exception "sessions staying open"). It reaches
/// this step through `AppModel.flushPendingWork(for:)` rather than re-listing the three flushes,
/// so their order still has one definition.
public func flushPendingWork() async {
await storeFlush() await storeFlush()
await editorFlush?() await editorFlush?()
await committerFlush?() await committerFlush?()
+232
View File
@@ -0,0 +1,232 @@
import AppKit
import SwiftUI
import os
/// The template chooser File New Board (N), 09-templates.md's picker.
///
/// ### Pages' shape, one card in it
///
/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected
/// at a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a
/// Pages-style chooser with a mini per-lane preview per template"). The grid holds exactly one card
/// today because exactly one template exists (`BoardTemplate`); everything about the layout is
/// already the plural case, so the m9 inventory drops in without the surface changing shape.
///
/// ### Choosing is three steps, and the middle one is a save panel
///
/// Choose runs `NSSavePanel`, instantiates into the chosen location, and opens the result. The panel
/// is not ceremony: a sandboxed app cannot write anywhere the user has not pointed at, so the panel
/// *is* how a new board gets a location it is allowed to occupy (09 files this under "seed the save
/// panel's suggested name"). The chooser stays open if the panel is cancelled a cancelled location
/// is not a cancelled choice.
///
/// ### Failure is an alert here, deliberately
///
/// Everywhere else in the app a failed write is a banner in the window that produced it
/// (02-architecture.md § Write-failure surfacing). A failed *create* has no such window: the board
/// that would host the banner is the one that did not get created, welcome may not be open (N
/// works from a board window), and filing it under `AppModel.launchFailures` would put it in a
/// recents-adjacent list belonging to boards the registry knows which this one, having never
/// existed, is not. So it is an `NSAlert`, continuing the modal conversation the user is already in
/// with the save panel, in the same sentence the banner would have used
/// (`BannerCenter.headline(for:)`) so the app has one vocabulary for a failed write rather than two.
struct TemplateChooserView: View {
@Environment(AppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var selection: BoardTemplate.ID = BoardTemplate.basic.id
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
private var selected: BoardTemplate? {
BoardTemplate.all.first { $0.id == selection }
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
grid
Divider()
footer
}
.frame(width: 620, height: 460)
}
// MARK: Header
private var header: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Choose a Template")
.font(.title3.weight(.semibold))
Text("Every template is an ordinary board — lanes and cards you can change afterwards.")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(20)
}
// MARK: Grid
private var grid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
ForEach(BoardTemplate.all) { template in
TemplateCard(template: template, isSelected: template.id == selection)
.onTapGesture { selection = template.id }
// The list convention welcome's recents use, for the same reason: a
// double click is how a chooser is answered without reaching for a button.
.onTapGesture(count: 2) { choose() }
.accessibilityAddTraits(template.id == selection ? [.isSelected] : [])
}
}
.padding(20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .controlBackgroundColor))
}
// MARK: Footer
private var footer: some View {
HStack(alignment: .firstTextBaseline) {
Text(selected?.blurb ?? "")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer(minLength: 16)
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button("Choose") { choose() }
.keyboardShortcut(.defaultAction)
.disabled(selected == nil)
}
.padding(20)
}
// MARK: - Choosing
/// Panel, instantiate, open and only then dismiss, so a cancelled panel leaves the chooser
/// exactly as the user left it.
private func choose() {
guard let template = selected, let url = Self.chooseLocation(for: template) else { return }
do {
try template.instantiate(at: url)
} catch {
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
Self.present(error)
return
}
dismiss()
// The ordinary open path, so the new board joins recents, gets its bookmark, and closes
// welcome on the way in exactly like a board opened from a row.
appModel.openBoard(at: url)
}
/// The save panel where the board goes and what it is called.
///
/// `"Untitled.kanban"` is the suggestion; the package extension is visible and editable, because
/// an extension-less board folder is equally legal (01-storage-format.md § Document packaging)
/// and deleting the suffix should therefore work rather than be silently undone.
///
// m9-templates: 09 Instantiation seeds this name from the template's own title once templates
// have titles of their own ("Basic.kanban", "Bug Tracker.kanban"). With one stub template a
// suggestion of "Basic" would name the *template*, not the user's board, which is worse than
// Untitled.
///
/// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
/// anything (the panel never does), so `BoardWriter.createBoard`'s refusal to clobber an existing
/// board is what the user sees as an alert, naming the path. That is the honest outcome: this
/// flow is a *create*, and quietly replacing a board with an empty one is not a thing it should
/// be able to do.
private static func chooseLocation(for template: BoardTemplate) -> URL? {
let panel = NSSavePanel()
panel.nameFieldStringValue = "Untitled.kanban"
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowsOtherFileTypes = true
panel.prompt = "Create"
panel.message = "Choose where to keep the new board."
guard panel.runModal() == .OK, let url = panel.url else { return nil }
return url
}
private static func present(_ error: BoardWriteError) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = BannerCenter.headline(for: error)
alert.informativeText = error.path
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
// MARK: - Template card
/// One template in the grid: its mini per-lane preview, its name, and the selection ring.
private struct TemplateCard: View {
let template: BoardTemplate
let isSelected: Bool
var body: some View {
VStack(spacing: 8) {
TemplatePreview(template: template)
.frame(height: 96)
.background(RoundedRectangle(cornerRadius: 8).fill(Color(nsColor: .textBackgroundColor)))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isSelected ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: isSelected ? 3 : 1)
)
Label(template.name, systemImage: template.icon)
.font(.callout)
.labelStyle(.titleAndIcon)
}
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel(template.name)
.accessibilityHint(template.blurb)
}
}
/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes.
///
/// Deliberately abstract no text, because the point is the *shape* of the board and legible lane
/// names at this size are not available. It renders from `laneTitles` only for the count and the
/// stable identity of each column.
private struct TemplatePreview: View {
let template: BoardTemplate
var body: some View {
HStack(alignment: .top, spacing: 6) {
ForEach(Array(template.laneTitles.enumerated()), id: \.offset) { index, _ in
VStack(spacing: 4) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.accentColor.opacity(0.65))
.frame(height: 5)
// A descending number of cards, so the preview reads as work in flight rather
// than as three identical columns.
ForEach(0..<max(1, 3 - index), id: \.self) { _ in
RoundedRectangle(cornerRadius: 3)
.fill(.quaternary)
.frame(height: 14)
}
Spacer(minLength: 0)
}
}
}
.padding(10)
.accessibilityHidden(true)
}
}
+170
View File
@@ -0,0 +1,170 @@
import Foundation
// MARK: - WelcomeRow
/// One row of the welcome window's recents list: a `RecentBoard` joined with whatever launch failure
/// names the same board.
///
/// ### Why the join is a value, derived by a pure function
///
/// 02-architecture.md § Launch and window lifecycle makes the *row* the failure surface:
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
/// > proceed unaffected never a launch-time modal chain, never a silent drop.
///
/// That is a rule about *data*: which of three captions a row wears, which of its actions are live,
/// and the clause a list of rows cannot express by itself what happens to a failure that matches
/// no row at all. Derived here, every one of those cases is stateable in a test without a window,
/// which is the only way the "never a silent drop" half is checkable at all.
///
/// The same derivation feeds File Open Recent, so the submenu and the list can never disagree
/// about a board's name or about whether it can be opened.
struct WelcomeRow: Identifiable, Equatable {
/// The registry record's id the row's identity, the selection's value, and what Forget names.
let id: UUID
/// The board's name: the record's cached `displayName`, falling back to the folder name for a
/// record that somehow carries none (01-storage-format.md § Board naming's fallback, applied to
/// the cached string rather than to a board this window must never open).
let displayName: String
/// Where the board is **now**, or `nil` when its bookmark no longer resolves. The single source
/// of the row's availability: Open and Reveal need a URL, and an orphan has none.
let url: URL?
/// The containing folder, for the row's location line Xcode's welcome shows where a project
/// lives, not its own path repeated under its name. Home-abbreviated where it can be.
let location: String
/// The counts stamped at last close, `nil` until a close has stamped them
/// (02 § Per-board app state: registry-cached, never a directory scan at welcome time).
let laneCount: Int?
let cardCount: Int?
/// The message of the launch failure naming this board, if one does.
let failure: String?
var isAvailable: Bool { url != nil }
/// Open and Reveal in Finder both need somewhere to go; Forget is deliberately not here, because
/// it is enabled on every row an orphan the user can never open is exactly the row that most
/// needs erasing (02 § Graceful orphaning: "recents surface it as unavailable with Forget").
var canOpen: Bool { isAvailable }
var canReveal: Bool { isAvailable }
/// The row's third line one line, so the three states are alternatives rather than a stack.
///
/// The precedence is 02's sentence read in order: a failure is what the row is *for* at that
/// moment and outranks both the orphan state (which the failure message already describes in
/// better words) and the counts (facts about a board the user cannot currently get into).
enum Caption: Equatable {
/// The ordinary row: "3 lanes · 12 cards", or an em dash where nothing has been stamped.
case counts(lanes: Int?, cards: Int?)
/// The bookmark no longer resolves (02 § Graceful orphaning).
case unavailable
/// Fail-fast's specifics, from the open or restore that failed.
case failed(String)
}
var caption: Caption {
if let failure { return .failed(failure) }
if url == nil { return .unavailable }
return .counts(lanes: laneCount, cards: cardCount)
}
/// "3 lanes · 12 cards" or "" when the record has never been closed and so carries nothing.
///
/// A single em dash rather than "0 lanes · 0 cards": an unstamped record knows nothing about the
/// board's size, and zero is a claim.
var countsSummary: String {
guard let laneCount, let cardCount else { return "" }
let lanes = "\(laneCount) lane\(laneCount == 1 ? "" : "s")"
let cards = "\(cardCount) card\(cardCount == 1 ? "" : "s")"
return "\(lanes) · \(cards)"
}
// MARK: - Derivation
/// The rows, and the failures no row could carry.
struct Derivation: Equatable {
var rows: [WelcomeRow]
/// Failures naming no record a first open of a folder that turned out not to be a board,
/// which fails before anything is registered and so has no row to render on. They keep a
/// list of their own on welcome, because the alternative is the silent drop 02 rules out.
var unmatched: [LaunchFailure]
}
/// Joins the recents list with the launch failures, in `recents`' order (which is the registry's
/// `lastOpened` descending this function never re-sorts, so the sort rule keeps living in
/// exactly one place).
///
/// **Matching is by path, and by both of a record's paths.** A record knows where it was last
/// seen (`lastKnownPath`) and, when its bookmark resolves, where it lives now; a bookmark follows
/// a move, so a failure recorded before one names the older spelling. Paths are standardized
/// before comparison `/tmp/b/../b` and `/tmp/b` are one board but never resolved through
/// symlinks: that would be a filesystem round trip per row, which is the cost the registry's
/// whole design is built to avoid at welcome time.
///
/// **The newest failure wins a row's caption** when several name it (a board that failed, was
/// retried, and failed again), because it is the one describing the state the file is in now.
/// All of them are consumed either way a row carries one message, and the older attempts must
/// not resurface in the unmatched list as if nothing had shown them.
static func derive(recents: [RecentBoard], failures: [LaunchFailure]) -> Derivation {
var claimed: Set<UUID> = []
let rows = recents.map { recent -> WelcomeRow in
let record = recent.record
var keys: Set<String> = [pathKey(record.lastKnownPath)]
if let url = recent.url {
keys.insert(pathKey(url.path))
}
let matches = failures.filter { keys.contains(pathKey($0.path)) }
claimed.formUnion(matches.map(\.id))
return WelcomeRow(
id: record.id,
displayName: record.displayName.isEmpty
? URL(fileURLWithPath: record.lastKnownPath).deletingPathExtension().lastPathComponent
: record.displayName,
url: recent.url,
location: location(of: recent.url?.path ?? record.lastKnownPath),
laneCount: record.laneCount,
cardCount: record.cardCount,
failure: matches.last?.message
)
}
return Derivation(rows: rows, unmatched: failures.filter { !claimed.contains($0.id) })
}
/// How two paths are compared for "the same board" on this screen. Standardized only see
/// `derive` for why nothing here touches the filesystem.
static func pathKey(_ path: String) -> String {
URL(fileURLWithPath: path).standardizedFileURL.path
}
/// The board's containing folder, with the user's home written as `~`.
static func location(of path: String) -> String {
let parent = URL(fileURLWithPath: path).deletingLastPathComponent().path
guard let home = realHomeDirectory, parent == home || parent.hasPrefix(home + "/") else {
return parent
}
return "~" + parent.dropFirst(home.count)
}
/// The user's **real** home directory.
///
/// `NSHomeDirectory()` and `FileManager.homeDirectoryForCurrentUser` both answer with the sandbox
/// container, which no board is ever inside abbreviating against either would never once fire.
/// The password database is where the real path still lives, and this is display text only:
/// nothing is opened, resolved, or written relative to it, so being wrong costs a longer row.
private static let realHomeDirectory: String? = {
guard let entry = getpwuid(getuid()), let directory = entry.pointee.pw_dir else { return nil }
return String(cString: directory)
}()
}
+250 -42
View File
@@ -1,25 +1,51 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
/// The welcome window (02-architecture.md § Windows). /// The welcome window (02-architecture.md § Windows; 03-board-ui.md § Welcome screen & templates).
/// ///
/// ### What this is, and what it is not yet /// ### Xcode's shape, which 03 says carries over unchanged
/// ///
/// The settled shape is Xcode's: "branding + actions left, recents right (board icon, name, /// > Welcome: resizable, no title bar (background drag); recents list with board icon, name,
/// location, lane/card counts, sorted by last opened)". This is the left half, plus the one thing /// > location, counts; single click selects, double click opens; context menu Open / Reveal in
/// that cannot wait the list of boards that failed to open, because 02 § Launch and window /// > Finder / Forget.
/// lifecycle forbids a launch-time failure from being silently dropped and welcome is where it must
/// surface.
/// ///
/// The layout is therefore already an `HStack` with one column in it. The recents column drops in /// Branding and the two create/open actions on the left, recents on the right. Resizable the
/// beside it; nothing here has to move. /// recents list gets whatever space the user grants it and title-bar-less, with the background as
// m4-welcome: the recents column, New Board / Open Recent, per-row Forget and Reveal in Finder, and /// the drag surface (the gesture is attached at low priority, so a row or a button always wins).
// the row-level failure rendering 02 specifies (a failed board's own row carrying fail-fast's ///
// specifics, or the unavailable state per Graceful orphaning) all land with the welcome milestone. /// ### The rows are derived, not assembled here
///
/// Everything a row *says* which caption it wears, whether Open and Reveal are live, and which
/// failures had no row to land on is `WelcomeRow.derive(recents:failures:)`, a pure function over
/// the registry's recents and `AppModel.launchFailures`. That is where 02's row-level failure rule
/// lives, and it is why the rule is testable. This file renders the answer.
///
/// ### What it never does
///
/// It never opens a board's `index.md` not for a title, not for a count, not for an icon. Counts
/// and names come from the registry record, stamped at last close: "no directory scan at welcome
/// time (which would be slow or hang on big/unavailable boards)" (02 § Per-board app state). The
/// pathfinder loaded every board to build this list; that is the one thing about it that does not
/// carry over.
struct WelcomeView: View { struct WelcomeView: View {
@Environment(AppModel.self) private var appModel @Environment(AppModel.self) private var appModel
/// The selected row's record id. A row's identity is its record, so a Forget leaves this
/// pointing at nothing, which reads as "no selection" without any cleanup of its own.
@State private var selection: UUID?
/// Whether the recents list holds the keyboard, so Return can mean "open the selected row".
@FocusState private var listFocused: Bool
private var derivation: WelcomeRow.Derivation {
WelcomeRow.derive(recents: appModel.recents, failures: appModel.launchFailures)
}
private var selectedRow: WelcomeRow? {
derivation.rows.first { $0.id == selection }
}
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
branding branding
@@ -29,14 +55,19 @@ struct WelcomeView: View {
Divider() Divider()
failures recents
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(32)
} }
// Fixed, with `.windowResizability(.contentSize)` on the scene: welcome is a launcher, not a .frame(minWidth: 760, minHeight: 460)
// workspace, and Xcode's the window this one is modelled on does not resize either. The // The window has no title bar, so the background is the drag handle. `.gesture` rather than
// one thing that can grow without bound is the failure list, which scrolls. // `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
.frame(width: 760, height: 460) .gesture(WindowDragGesture())
// Belt and braces over the explicit refreshes `AppModel` runs on every registry mutation:
// welcome is the one surface that can appear long after the last thing that changed the list.
.onAppear { appModel.refreshRecents() }
// What File Reveal in Finder acts on in this window's scope (11-command-nexus.md: "welcome:
// the selected recent's folder (disabled on unavailable rows)").
.focusedSceneValue(\.welcomeSelection, selectedRow)
} }
// MARK: Branding and actions // MARK: Branding and actions
@@ -56,12 +87,24 @@ struct WelcomeView: View {
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Folders and Markdown, on your terms.")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, 4)
Spacer(minLength: 24) Spacer(minLength: 24)
Button("Open Board…") { VStack(spacing: 8) {
// The menu-bar twin of this button is File New Board (N) same action, and
// deliberately the same words, because a button and a menu item that differ read as
// two features.
WelcomeActionButton(title: "New Board…", systemImage: "plus.square") {
appModel.showTemplateChooser()
}
WelcomeActionButton(title: "Open Board…", systemImage: "folder") {
appModel.presentOpenPanel() appModel.presentOpenPanel()
} }
.controlSize(.large) }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
@@ -73,30 +116,87 @@ struct WelcomeView: View {
return "Version \(short) (\(build))" return "Version \(short) (\(build))"
} }
// MARK: Failed opens // MARK: Recents
@ViewBuilder @ViewBuilder
private var failures: some View { private var recents: some View {
if appModel.launchFailures.isEmpty { let derivation = self.derivation
VStack(alignment: .leading, spacing: 8) {
Text("No boards open")
.font(.title3)
Text("Open a board folder to get started.")
.foregroundStyle(.secondary)
}
} else {
VStack(alignment: .leading, spacing: 12) {
Text("Couldn't open")
.font(.title3)
ScrollView { VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 12) { if derivation.rows.isEmpty {
ForEach(appModel.launchFailures) { failure in emptyHint
VStack(alignment: .leading, spacing: 2) { } else {
Text(failure.displayName) list(derivation.rows)
}
if !derivation.unmatched.isEmpty {
Divider()
unmatchedFailures(derivation.unmatched)
}
}
.background(Color(nsColor: .controlBackgroundColor))
}
private var emptyHint: some View {
VStack(spacing: 6) {
Image(systemName: "clock")
.font(.title)
.foregroundStyle(.tertiary)
Text("No Recent Boards")
.font(.headline) .font(.headline)
Text(failure.message) .foregroundStyle(.secondary)
Text("Boards you create or open appear here.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func list(_ rows: [WelcomeRow]) -> some View {
List(rows, selection: $selection) { row in
RecentBoardRow(row: row)
// Single click selects (the `List` does that); the second click of a double click
// opens. `simultaneousGesture` rather than `onTapGesture` so the list's own
// selection handling still sees the first click.
.simultaneousGesture(TapGesture(count: 2).onEnded { open(row) })
.contextMenu {
Button("Open") { open(row) }
.disabled(!row.canOpen)
Button("Reveal in Finder") { reveal(row) }
.disabled(!row.canReveal)
Divider()
// Always enabled, on every row: an orphan the user can never open again is
// exactly the row that most needs erasing (02 § Graceful orphaning).
Button("Forget") { appModel.forget(boardID: row.id) }
}
}
.listStyle(.inset)
.scrollContentBackground(.hidden)
.focused($listFocused)
// Return on a selected row opens it the list convention, and the reason the list takes
// focus on a click rather than only on Tab.
.onKeyPress(.return) {
guard let selectedRow, selectedRow.canOpen else { return .ignored }
open(selectedRow)
return .handled
}
.onTapGesture { listFocused = true }
}
/// The failures no recents row could carry a first open of a folder that turned out not to be
/// a board fails before anything is registered, so there is no row for it to land on. A list of
/// their own, because the alternative is the silent drop 02 rules out.
private func unmatchedFailures(_ failures: [LaunchFailure]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text("Couldn't Open")
.font(.subheadline.weight(.semibold))
ForEach(failures) { failure in
VStack(alignment: .leading, spacing: 1) {
Text(failure.displayName)
.font(.callout) .font(.callout)
Text(failure.message)
.font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Text(failure.path) Text(failure.path)
@@ -107,13 +207,121 @@ struct WelcomeView: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
// Clears exactly the failures listed here, never the ones standing on rows above: those
// are still describing a board the user can see, and one button quietly erasing both
// lists would be the drop 02 forbids wearing a different hat.
Button("Clear") {
appModel.clearLaunchFailures(ids: Set(failures.map(\.id)))
} }
.controlSize(.small)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
} }
Button("Clear") { // MARK: Actions
appModel.clearLaunchFailures()
/// Opens a row's board. Welcome closes itself on the way in that is the board window host's
/// job ("Opening a board from welcome closes welcome"), not this view's, because the close has to
/// wait for the load to actually succeed.
private func open(_ row: WelcomeRow) {
guard let url = row.url else { return }
appModel.openBoard(at: url)
} }
private func reveal(_ row: WelcomeRow) {
guard let url = row.url else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
} }
}
// MARK: - Pieces
/// A full-width, leading-aligned action button Xcode's welcome column.
private struct WelcomeActionButton: View {
let title: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}
.buttonStyle(.bordered)
.controlSize(.large)
}
}
/// One recents row: icon, name, location, and the one caption line carrying whichever of the three
/// things the row has to say (`WelcomeRow.Caption`).
private struct RecentBoardRow: View {
let row: WelcomeRow
var body: some View {
HStack(spacing: 12) {
// The board default symbol, on every row.
//
// The record carries no icon. 02 § Per-board app state settles that it should "the
// row's title and icon are registry-cached too with live write-through" and today it
// holds only the display name and the counts. An `icon`/`iconColor` stamp joining
// `recordClose` (and the store's reload path, which is where the write-through half
// lives) is what turns this into the board's own glyph; until then a row that guessed
// would be worse than one that is honestly generic.
Image(systemName: ItemSymbol.board)
.font(.system(size: 22))
.foregroundStyle(.secondary)
.frame(width: 34, height: 34)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(row.displayName)
.font(.headline)
.lineLimit(1)
Text(row.location)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
caption
}
Spacer(minLength: 0)
}
.padding(.vertical, 4)
// Dimmed when the board cannot be reached the row stays, with Forget, rather than
// disappearing (02 § Graceful orphaning).
.opacity(row.isAvailable ? 1 : 0.55)
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var caption: some View {
switch row.caption {
case .counts:
Text(row.countsSummary)
.font(.caption)
.foregroundStyle(.tertiary)
case .unavailable:
Label("Unavailable — moved, deleted, or on a volume that isn't mounted",
systemImage: "questionmark.folder")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
case let .failed(message):
// The warning tint, and the whole of fail-fast's specifics this row *is* the failure
// surface (02 § Launch and window lifecycle).
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
} }
} }
} }
+34 -6
View File
@@ -55,11 +55,30 @@ struct KanbanApp: App {
} }
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic) .defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
.restorationBehavior(.disabled) .restorationBehavior(.disabled)
.windowResizability(.contentSize) // "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 // 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. // the one 11-command-nexus.md names rather than whatever the scene happens to be called.
.commandsRemoved() .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) { Window("", id: WindowID.restoreBootstrap) {
RestoreBootstrapView() RestoreBootstrapView()
.environment(appModel) .environment(appModel)
@@ -107,13 +126,15 @@ struct KanbanApp: App {
/// changing one silently breaks every remap of it. /// changing one silently breaks every remap of it.
@CommandsBuilder @CommandsBuilder
private var menuCommands: some Commands { private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board, // The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board,
// still owed), Open, (Open Recent, still owed), Board Info, (Duplicate / Save as Template / // Open, Open Recent , Board Info, Duplicate, (Save as Template, still owed 09),
// Reveal in Finder, still owed), then the trash trio and Empty Trash. Every one of them // Reveal in Finder, then the trash trio and Empty Trash. The board-scoped ones validate
// validates against the frontmost board through the focus system, so each is simply // against the frontmost board through the focus system, so each is simply absent-of-effect
// absent-of-effect when no board is in front. // when no board is in front; New Board and Open Recent are available everywhere, including
// with no window at all.
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
BoardCreationCommands() BoardCreationCommands()
NewBoardCommand(appModel: appModel)
Divider() Divider()
@@ -122,12 +143,19 @@ struct KanbanApp: App {
} }
.keyboardShortcut("o", modifiers: .command) .keyboardShortcut("o", modifiers: .command)
OpenRecentMenu(appModel: appModel)
Divider() Divider()
BoardInfoCommand() BoardInfoCommand()
Divider() Divider()
DuplicateBoardCommand(appModel: appModel)
RevealInFinderCommand()
Divider()
TrashCommands() TrashCommands()
} }
+2
View File
@@ -458,6 +458,8 @@ public final class BannerCenter {
// looking at which is what makes "Couldn't rename 'Fix login'" (02's own example // looking at which is what makes "Couldn't rename 'Fix login'" (02's own example
// sentence) identify the right row rather than a name that never landed. // sentence) identify the right row rather than a name that never landed.
if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" } if let title { "Couldn't rename '\(title)'" } else { "Couldn't rename the item" }
case let .duplicateBoard(title):
if let title { "Couldn't duplicate '\(title)'" } else { "Couldn't duplicate the board" }
case let .importAttachment(filename): case let .importAttachment(filename):
"Couldn't import '\(filename)'" "Couldn't import '\(filename)'"
case .listAttachments: case .listAttachments:
+15
View File
@@ -426,6 +426,21 @@ public final class BoardRegistry {
save() save()
} }
/// Drops every record File Open Recent Clear Menu (11-command-nexus.md).
///
/// **Finder's Clear Menu clears the menu; this registry *is* the menu**, so the equivalence is
/// exact: there is no separate recents list that could be emptied while the records stayed, and
/// a record whose board never appears anywhere is a setting nothing can reach. It is therefore
/// `forget(id:)` applied to every row, and the one test worth writing says exactly that.
///
/// One save rather than one per record: the file is rewritten wholesale anyway, and forgetting
/// twenty boards should not be twenty writes.
public func forgetAll() {
guard !records.isEmpty else { return }
records.removeAll()
save()
}
// MARK: - Matching // MARK: - Matching
/// The index of the record whose bookmark resolves to the same file as `url`, if any. /// The index of the record whose bookmark resolves to the same file as `url`, if any.
+9
View File
@@ -1192,6 +1192,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// the edit `updateIndex` enriches it off the document it just read which is the name the /// the edit `updateIndex` enriches it off the document it just read which is the name the
/// user is still looking at when the banner appears. /// user is still looking at when the banner appears.
case rename(title: String?) case rename(title: String?)
/// File Duplicate the whole-board copy (03-board-ui.md § Welcome screen & templates). Its
/// own case rather than a fold into `.copy`, on `.rename`'s reasoning: the user pressed
/// *Duplicate*, and a banner telling them the app could not "copy the item" would name neither
/// the command nor the thing. `title` is the board's display name.
case duplicateBoard(title: String?)
case importAttachment(filename: String) case importAttachment(filename: String)
case listAttachments case listAttachments
case renumberChildren // order-maintenance sweep (compaction) case renumberChildren // order-maintenance sweep (compaction)
@@ -1218,6 +1225,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .style: .style(title: title) case .style: .style(title: title)
case .resize: .resize(title: title) case .resize: .resize(title: title)
case .rename: .rename(title: title) case .rename: .rename(title: title)
case .duplicateBoard: .duplicateBoard(title: title)
} }
} }
@@ -1241,6 +1249,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .style(title): Self.phrase("style", title) case let .style(title): Self.phrase("style", title)
case let .resize(title): Self.phrase("resize", title) case let .resize(title): Self.phrase("resize", title)
case let .rename(title): Self.phrase("rename", title) case let .rename(title): Self.phrase("rename", title)
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .importAttachment(filename): "import attachment '\(filename)'" case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments" case .listAttachments: "list attachments"
case .renumberChildren: "renumber children" case .renumberChildren: "renumber children"
+189
View File
@@ -0,0 +1,189 @@
import Foundation
import Testing
@testable import Kanban
/// File Duplicate's copy (03-board-ui.md § Welcome screen & templates).
///
/// Three promises, and all three are about what the copy *doesn't* do:
///
/// - it doesn't remint "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out,
/// and the thing that keeps a copied `.git` history naming paths that still exist;
/// - it doesn't drop the trash "Tombstoned items are carried too (settled) ... the duplicate is
/// born exactly matching its history";
/// - it doesn't overwrite the Finder-style `copy` ladder renames instead.
///
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
/// window flow around the copy (the flush, the banner row, the open that follows) is the command's,
/// not this type's.
// MARK: - Fixtures
/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file
/// everything a literal copy has to carry through untouched.
@MainActor
private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) {
let fixture = try WriterFixture()
let root = fixture.url(name)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
try fixture.item(name, Item.board)
try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try fixture.item(
"\(name)/\(Ident.lane1)/\(Ident.card2)",
"---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item(
"\(name)/\(Ident.lane2)",
"---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
)
try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried"))
try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8))
return (fixture, root)
}
/// Every path under `root`, board-root-relative, hidden entries included what "a literal copy"
/// means as an assertion.
private func tree(of root: URL) throws -> Set<String> {
var paths: Set<String> = []
let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
while let url = walker?.nextObject() as? URL {
paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
}
return paths
}
// MARK: - Tests
@MainActor
@Suite("BoardDuplicator")
struct BoardDuplicatorTests {
// MARK: The name ladder
@Test("The first duplicate is a 'copy' sibling, extension carried")
func firstCopyIsNamedFinderStyle() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let destination = BoardDuplicator.copyDestination(for: board.root)
#expect(destination.lastPathComponent == "Roadmap copy.kanban")
#expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path,
"a sibling — where the user is already looking")
}
@Test("An extension-less board has none to carry")
func extensionlessBoardCopiesWithoutOne() throws {
let board = try makeBoard(named: "Plain")
defer { board.fixture.tearDown() }
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy")
}
@Test("The collision ladder counts up from 2, one collision at a time")
func collisionLadderCountsUp() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(first.lastPathComponent == "Roadmap copy.kanban")
let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(second.lastPathComponent == "Roadmap copy 2.kanban")
let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(third.lastPathComponent == "Roadmap copy 3.kanban")
// And duplicating a duplicate ladders off *its* name, exactly as Finder does.
let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy")
#expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban")
}
@Test("Anything already wearing the name blocks it, folder or file alike")
func anyExistingEntryBlocksTheName() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
// Not a board just a file in the way. A duplicate must rename around it, never through it.
try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8))
#expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban")
}
// MARK: What lands in the copy
@Test("Every GUID is kept — the whole-board carve-out from the remint rule")
func guidsArePreserved() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(try tree(of: copy) == tree(of: board.root),
"same identities, same nesting — a copied history keeps naming paths that exist")
#expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path))
#expect(FileManager.default.fileExists(
atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path
))
}
@Test("Tombstoned lanes and cards are carried, trash included")
func tombstonesAreCarried() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
let loaded = try BoardLoader.load(boardRoot: copy).model
let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 })
#expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork")
#expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it")
let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 })
#expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted })
}
@Test("The copy is byte-for-byte, strays and stale attribution included")
func contentIsCopiedVerbatim() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
func bytes(_ root: URL, _ relative: String) throws -> Data {
try Data(contentsOf: root.appendingPathComponent(relative))
}
#expect(try bytes(copy, "index.md") == bytes(board.root, "index.md"))
#expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md")
== bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"),
"created, unknown keys and modified-by all survive — nothing here reads a board file")
#expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md"))
}
@Test("The original is untouched by its own duplication")
func originalSurvivesUnchanged() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let before = try tree(of: board.root)
_ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
#expect(try tree(of: board.root) == before)
}
// MARK: Failure
@Test("A source that isn't there fails as a duplicate, naming the board")
func missingSourceFailsInTheDuplicateVocabulary() throws {
let board = try makeBoard(named: "Roadmap.kanban")
defer { board.fixture.tearDown() }
let missing = board.fixture.url("Never.kanban")
let error = writeFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }
#expect(error?.operation == .duplicateBoard(title: "Never"),
"the user pressed Duplicate; the banner must say so")
#expect(BannerCenter.headline(for: try #require(error)).hasPrefix("Couldn't duplicate 'Never'"))
}
}
+70
View File
@@ -226,6 +226,76 @@ struct BoardRegistryTests {
#expect(registry.recents().isEmpty) #expect(registry.recents().isEmpty)
} }
// MARK: Clear Menu
@Test("Clear Menu empties the registry, and persists")
func forgetAllEmptiesTheRegistry() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
let first = try makeBoard()
defer { first.tearDown() }
let second = try makeBoard()
defer { second.tearDown() }
let registry = BoardRegistry(storageURL: storage.url)
registry.recordOpen(of: first.root, displayName: "First")
registry.recordOpen(of: second.root, displayName: "Second")
#expect(registry.recents().count == 2)
registry.forgetAll()
#expect(registry.recents().isEmpty)
#expect(BoardRegistry(storageURL: storage.url).recents().isEmpty, "Clear Menu persists")
}
/// The equivalence 11-command-nexus.md's Clear Menu rests on: Finder clears a *menu*, and here
/// the registry **is** the menu so clearing it can only mean forgetting every record, and must
/// leave the file in precisely the state that forgetting them one at a time would.
@Test("Clear Menu is Forget applied to every row — same result, same file")
func forgetAllMatchesForgettingEachRow() async throws {
let wholesale = try RegistryStorage()
defer { wholesale.tearDown() }
let piecemeal = try RegistryStorage()
defer { piecemeal.tearDown() }
let first = try makeBoard()
defer { first.tearDown() }
let second = try makeBoard()
defer { second.tearDown() }
func populate(_ storage: RegistryStorage) -> BoardRegistry {
let registry = BoardRegistry(storageURL: storage.url)
registry.recordOpen(of: first.root, displayName: "First")
registry.recordOpen(of: second.root, displayName: "Second")
return registry
}
let bulk = populate(wholesale)
bulk.forgetAll()
let oneByOne = populate(piecemeal)
for row in oneByOne.recents() {
oneByOne.forget(id: row.record.id)
}
#expect(bulk.recents().isEmpty)
#expect(oneByOne.recents().isEmpty)
#expect(
try Data(contentsOf: wholesale.url) == Data(contentsOf: piecemeal.url),
"the two paths leave byte-identical files — there is no state Clear Menu skips"
)
}
@Test("Clear Menu on an empty registry writes nothing")
func forgetAllOnEmptyRegistryIsANoOp() async throws {
let storage = try RegistryStorage()
defer { storage.tearDown() }
let registry = BoardRegistry(storageURL: storage.url)
registry.forgetAll()
#expect(try storage.entryNames().isEmpty, "an empty registry has nothing to clear and no file to write")
}
// MARK: Persistence // MARK: Persistence
@Test("Every mutation survives a reload of the file, dates included") @Test("Every mutation survives a reload of the file, dates included")
+119
View File
@@ -0,0 +1,119 @@
import Foundation
import Testing
@testable import Kanban
/// Template instantiation the on-disk half of File New Board (09-templates.md Instantiation).
///
/// The window flow around it (the chooser, `NSSavePanel`, the open that follows) is untestable
/// without a screen and deliberately holds no rules of its own; everything that *is* a rule what
/// gets written, what the board is called, and the order the lanes land in lives in
/// `BoardTemplate.instantiate(at:)` and is checked here against a real temp folder, through the
/// app's own loader.
// MARK: - Fixtures
/// An empty temp folder to create *into* the save panel's answer, minus the panel.
private func temporaryLocation(named name: String) throws -> (url: URL, tearDown: () -> Void) {
let container = FileManager.default.temporaryDirectory
.appendingPathComponent("BoardTemplateTests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true)
return (container.appendingPathComponent(name, isDirectory: true), {
try? FileManager.default.removeItem(at: container)
})
}
// MARK: - Tests
@Suite("BoardTemplate")
struct BoardTemplateTests {
@Test("Basic writes a board and its three lanes, in order")
func basicInstantiatesInOrder() throws {
let location = try temporaryLocation(named: "Roadmap.kanban")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
let result = try BoardLoader.load(boardRoot: location.url)
#expect(result.warnings.isEmpty, "a board this app just wrote must load clean")
#expect(result.model.lanes.map { $0.title.value } == ["To Do", "Doing", "Done"])
#expect(result.model.lanes.allSatisfy { !$0.isDeleted })
#expect(result.model.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards")
}
@Test("The lanes are ranked by the board convention — 1024 apart, from 1024")
func lanesAreRankedByTheAppendConvention() throws {
let location = try temporaryLocation(named: "Ranked.kanban")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
let orders = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.order)
#expect(orders == [1024, 2048, 3072], "each lane appends after the one before it")
}
@Test("The board's title is the document name the user chose, not the template's")
func titleComesFromTheChosenName() throws {
let location = try temporaryLocation(named: "Q3 Planning.kanban")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
// 01-storage-format.md § Board naming: display name and folder name start out matching.
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Q3 Planning")
}
@Test("An extension-less location is as legal a board, and keeps its whole name as the title")
func extensionlessLocationWorks() throws {
let location = try temporaryLocation(named: "Plain")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
#expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Plain")
}
@Test("Every lane folder is a fresh lowercase UUID")
func laneFoldersAreMintedIdentities() throws {
let location = try temporaryLocation(named: "Minted.kanban")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
let ids = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.id.rawValue)
#expect(ids.count == 3)
#expect(Set(ids).count == 3, "three lanes, three identities")
#expect(ids.allSatisfy { $0 == $0.lowercased() }, "the app emits lowercase UUIDs")
#expect(ids.allSatisfy { UUID(uuidString: $0) != nil })
}
@Test("Instantiating over an existing board refuses rather than clobbering it")
func refusesToOverwriteAnExistingBoard() throws {
let location = try temporaryLocation(named: "Taken.kanban")
defer { location.tearDown() }
try BoardTemplate.basic.instantiate(at: location.url)
let before = try Data(contentsOf: location.url.appendingPathComponent("index.md"))
let error = writeFailure { try BoardTemplate.basic.instantiate(at: location.url) }
#expect(error?.operation == .createBoard)
#expect(
try Data(contentsOf: location.url.appendingPathComponent("index.md")) == before,
"the existing board is untouched — a create never replaces one"
)
}
@Test("The document name behind a chosen URL is the folder name without its extension")
func documentNameStripsTheExtension() {
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap.kanban")) == "Roadmap")
#expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap")) == "Roadmap")
}
@Test("The chooser offers Basic, and Basic is first")
func inventoryHoldsBasicFirst() {
// m9-templates: this becomes the bundled inventory's ten, with Basic still first
// (09-templates.md Inventory).
#expect(BoardTemplate.all.first == BoardTemplate.basic)
#expect(BoardTemplate.basic.slug == "basic")
}
}
+241
View File
@@ -0,0 +1,241 @@
import Foundation
import Testing
@testable import Kanban
/// The welcome window's row derivation 02-architecture.md § Launch and window lifecycle's
/// row-level failure rule, which is the one part of that screen a test can hold to account:
///
/// > A restored board that fails surfaces on welcome, row-level: its window doesn't open; welcome
/// > appears alongside whatever did restore, the failed board's recents row carrying fail-fast's
/// > specifics (load error) or the unavailable state per Graceful orphaning. Other restorations
/// > proceed unaffected never a launch-time modal chain, **never a silent drop**.
///
/// "Never a silent drop" is the clause with teeth: a failure that matches no row has to come out
/// somewhere, and the only way to know it does is to ask the function that decides.
// MARK: - Fixtures
/// A record with nothing in it that matters except what a given test is about. The bookmark is empty
/// because this function never resolves one `RecentBoard` is constructed directly here, so the
/// availability classification is an input rather than a filesystem outcome.
private func record(
name: String,
at path: String,
lanes: Int? = nil,
cards: Int? = nil,
opened: Date = Date()
) -> BoardRecord {
BoardRecord(
bookmark: Data(),
displayName: name,
lastKnownPath: path,
lastOpened: opened,
laneCount: lanes,
cardCount: cards
)
}
private func available(_ record: BoardRecord, at path: String? = nil) -> RecentBoard {
.available(record, at: URL(fileURLWithPath: path ?? record.lastKnownPath, isDirectory: true))
}
// MARK: - Tests
@Suite("WelcomeRow")
struct WelcomeRowTests {
// MARK: The three states
@Test("An available record with stamped counts is an ordinary row")
func availableRow() throws {
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban", lanes: 3, cards: 12))
let derived = WelcomeRow.derive(recents: [recent], failures: [])
#expect(derived.rows.count == 1)
let row = try #require(derived.rows.first)
#expect(row.displayName == "Roadmap")
#expect(row.isAvailable)
#expect(row.canOpen)
#expect(row.canReveal)
#expect(row.caption == .counts(lanes: 3, cards: 12))
#expect(row.countsSummary == "3 lanes · 12 cards")
#expect(derived.unmatched.isEmpty)
}
@Test("A record that has never been closed shows an em dash, not zeroes")
func unstampedCountsShowAPlaceholder() throws {
let recent = available(record(name: "Fresh", at: "/Boards/Fresh"))
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
#expect(row.caption == .counts(lanes: nil, cards: nil))
#expect(row.countsSummary == "", "zero is a claim; an unstamped record makes none")
}
@Test("Counts are singular at one")
func countsPluralize() {
let recent = available(record(name: "Tiny", at: "/Boards/Tiny", lanes: 1, cards: 1))
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.countsSummary == "1 lane · 1 card")
}
@Test("An unresolvable bookmark is a dimmed row with Open and Reveal off — never a missing row")
func unavailableRow() throws {
let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Archive", lanes: 4, cards: 9))
let row = try #require(WelcomeRow.derive(recents: [recent], failures: []).rows.first)
#expect(!row.isAvailable)
#expect(!row.canOpen)
#expect(!row.canReveal)
#expect(row.caption == .unavailable)
#expect(row.displayName == "Archive", "an orphan still says which board it was")
}
// MARK: The failure join
@Test("A failure naming a row renders on that row instead of in a list of its own")
func failureLandsOnItsRow() throws {
let recent = available(record(name: "Broken", at: "/Boards/Broken.kanban", lanes: 2, cards: 5))
let failure = LaunchFailure(path: "/Boards/Broken.kanban", message: "index.md: schema 7 is from a newer version")
let derived = WelcomeRow.derive(recents: [recent], failures: [failure])
let row = try #require(derived.rows.first)
#expect(row.caption == .failed("index.md: schema 7 is from a newer version"))
#expect(derived.unmatched.isEmpty, "the row carried it — it must not also appear in the fallback list")
}
@Test("A failure outranks the counts and the unavailable state alike")
func failureOutranksTheOtherCaptions() throws {
let orphan = RecentBoard.unavailable(record(name: "Offline", at: "/Volumes/NAS/Offline", lanes: 2, cards: 2))
let failure = LaunchFailure(path: "/Volumes/NAS/Offline", message: "This board is unavailable.")
let row = try #require(WelcomeRow.derive(recents: [orphan], failures: [failure]).rows.first)
#expect(row.caption == .failed("This board is unavailable."))
#expect(!row.canOpen, "the caption changed; the row is still an orphan")
}
@Test("A bookmark that followed a move matches a failure recorded at the older path")
func failureMatchesEitherOfARecordsPaths() {
// The record was last *seen* at the old path; its bookmark now resolves to the new one.
let moved = RecentBoard.available(
record(name: "Moved", at: "/Boards/Old.kanban"),
at: URL(fileURLWithPath: "/Boards/New.kanban", isDirectory: true)
)
let atOldPath = WelcomeRow.derive(
recents: [moved],
failures: [LaunchFailure(path: "/Boards/Old.kanban", message: "old")]
)
let atNewPath = WelcomeRow.derive(
recents: [moved],
failures: [LaunchFailure(path: "/Boards/New.kanban", message: "new")]
)
#expect(atOldPath.rows.first?.caption == .failed("old"))
#expect(atNewPath.rows.first?.caption == .failed("new"))
#expect(atOldPath.unmatched.isEmpty)
#expect(atNewPath.unmatched.isEmpty)
}
@Test("Paths are compared standardized, so two spellings of one board are one board")
func pathsAreStandardizedBeforeMatching() {
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
let failure = LaunchFailure(path: "/Boards/./Sub/../Roadmap.kanban", message: "couldn't read it")
let derived = WelcomeRow.derive(recents: [recent], failures: [failure])
#expect(derived.rows.first?.caption == .failed("couldn't read it"))
#expect(derived.unmatched.isEmpty)
}
@Test("The newest of several failures for one board is the caption, and all of them are consumed")
func newestFailureWinsAndOlderOnesAreNotOrphaned() {
let recent = available(record(name: "Retried", at: "/Boards/Retried"))
let failures = [
LaunchFailure(path: "/Boards/Retried", message: "first attempt"),
LaunchFailure(path: "/Boards/Retried", message: "second attempt"),
]
let derived = WelcomeRow.derive(recents: [recent], failures: failures)
#expect(derived.rows.first?.caption == .failed("second attempt"), "the newest describes the file as it is now")
#expect(derived.unmatched.isEmpty, "the older attempt must not resurface as if nothing had shown it")
}
@Test("A failure naming no record keeps a fallback of its own — never a silent drop")
func failureWithNoRowFallsBack() {
let recent = available(record(name: "Roadmap", at: "/Boards/Roadmap.kanban"))
let stray = LaunchFailure(path: "/Downloads/not-a-board", message: "index.md: no such file or directory")
let derived = WelcomeRow.derive(recents: [recent], failures: [stray])
#expect(derived.rows.count == 1)
#expect(derived.rows.first?.caption == .counts(lanes: nil, cards: nil), "the unrelated row is untouched")
#expect(derived.unmatched.map(\.message) == ["index.md: no such file or directory"])
}
@Test("Other rows are unaffected by a failure on one of them")
func failuresDoNotBleedBetweenRows() {
let recents = [
available(record(name: "Good", at: "/Boards/Good", lanes: 1, cards: 1)),
available(record(name: "Bad", at: "/Boards/Bad")),
]
let failure = LaunchFailure(path: "/Boards/Bad", message: "boom")
let derived = WelcomeRow.derive(recents: recents, failures: [failure])
#expect(derived.rows[0].caption == .counts(lanes: 1, cards: 1))
#expect(derived.rows[1].caption == .failed("boom"))
}
// MARK: Order and naming
@Test("The derivation preserves the registry's order and never re-sorts")
func orderIsTheRegistrys() {
let recents = [
available(record(name: "Third", at: "/Boards/C", opened: Date(timeIntervalSince1970: 3))),
available(record(name: "First", at: "/Boards/A", opened: Date(timeIntervalSince1970: 1))),
available(record(name: "Second", at: "/Boards/B", opened: Date(timeIntervalSince1970: 2))),
]
let derived = WelcomeRow.derive(recents: recents, failures: [])
#expect(derived.rows.map(\.displayName) == ["Third", "First", "Second"],
"the sort rule lives in BoardRegistry.recents() and must not be duplicated here")
}
@Test("A record with no display name falls back to its folder name, extension stripped")
func displayNameFallsBackToTheFolderName() {
let recent = available(record(name: "", at: "/Boards/Untitled.kanban"))
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.displayName == "Untitled")
}
@Test("The location line is the containing folder, not the board's own path")
func locationIsTheContainingFolder() {
let recent = available(record(name: "Roadmap", at: "/Boards/Work/Roadmap.kanban"))
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Boards/Work")
}
@Test("An unavailable row's location comes from where it was last seen")
func unavailableLocationUsesLastKnownPath() {
let recent = RecentBoard.unavailable(record(name: "Archive", at: "/Volumes/Gone/Boards/Archive"))
#expect(WelcomeRow.derive(recents: [recent], failures: []).rows.first?.location == "/Volumes/Gone/Boards")
}
@Test("An empty registry derives no rows and drops no failures")
func emptyRecentsStillSurfaceFailures() {
let stray = LaunchFailure(path: "/Downloads/whatever", message: "not a board")
let derived = WelcomeRow.derive(recents: [], failures: [stray])
#expect(derived.rows.isEmpty)
#expect(derived.unmatched.count == 1)
}
}
+5 -1
View File
@@ -11,7 +11,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards.
- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. - **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting.
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state. - **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (branding, failed-open reporting), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. - **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
@@ -20,6 +20,10 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu.
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched.
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it. - **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
## Development ## Development