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
+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")
}
}