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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user