Files
lanework/Kanban/App/WelcomeView.swift
T
rzen 2c6b8fe63a Remove the App Group wholesale — one sandbox, one bookmark, one flag
Phase 2 of the one-app pivot (DESIGN 12 ▸ App-side state, re-ruled
2026-07-30; reworks 566deab). AppGroup retires; what remains is
AppStateHome — ordinary sandbox Application Support as the one home for
the registry, clipboard staging and template stores, keeping the
unit-test-host redirect (the test host is the app and would sweep real
state). Scalar defaults return to UserDefaults.standard.

BoardRecord's per-edition grant slots and openNow flags collapse to one
bookmark + one isOpenNow; the legacy-key decode and adopt-in-memory
paths go (nothing shipped with group-era records), while the founding
four-keys-required / defaults-for-everything-since decode policy stays —
a bookmarkless record decodes as the born-orphan row rather than
quarantining the list. needsReopen and the pre-anchored re-grant panel
are removed whole: the only state that flow served — a record granted by
a sibling sandbox — is unrepresentable now, and a dead bookmark of our
own was already the orphan case by explicit comment. The
indexOfRecord path fallback dies with it; path is never a key again.

The cross-process freshness stamp (mtime+size re-read) and
BoardEditionPresence with its popover "Also open in…" line retire; the
clipboard prune keeps its atomic .sweeping/ claim-then-delete, reframed
for crash residue and open -n copies rather than sibling editions. The
application-groups entitlement key is gone.

1880 tests in 317 suites green (13 cross-edition tests retired with
their subject).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:46:32 -04:00

407 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// The welcome window (02-architecture.md § Windows; 03-board-ui.md § Welcome screen & templates).
///
/// ### Xcode's shape, which 03 says carries over unchanged
///
/// > Welcome: resizable, no title bar (background drag); recents list with board icon, name,
/// > location, counts; single click selects, double click opens; context menu Open / Reveal in
/// > Finder / Forget.
///
/// Branding and the two create/open actions on the left, recents on the right. Resizable — the
/// recents list gets whatever space the user grants it — and title-bar-less, with the background as
/// the drag surface (the gesture is attached at low priority, so a row or a button always wins).
///
/// ### 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 {
@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
// MARK: - Geometry
//
// Every figure this window lays out on, as a multiple of the body font — `BoardMetrics`' rule
// applied to the welcome window (10-accessibility.md ▸ Text scaling & visual accommodations). At
// the standard 13pt body they reproduce the numbers welcome has always drawn.
@MainActor static var pointSize: CGFloat { BoardMetrics.bodyPointSize }
@MainActor static var brandingColumnWidth: CGFloat { BoardMetrics.em(23, bodyPointSize: pointSize) }
@MainActor static var brandingInset: CGFloat { BoardMetrics.em(2.5, bodyPointSize: pointSize) }
@MainActor static var appIconSide: CGFloat { BoardMetrics.em(7.4, bodyPointSize: pointSize) }
@MainActor static var minimumWidth: CGFloat { BoardMetrics.em(58.5, bodyPointSize: pointSize) }
@MainActor static var minimumHeight: CGFloat { BoardMetrics.em(35.4, bodyPointSize: pointSize) }
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 {
HStack(spacing: 0) {
branding
.frame(width: Self.brandingColumnWidth)
.frame(maxHeight: .infinity)
.padding(Self.brandingInset)
Divider()
recents
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// Font-derived, like the board window's floor (10-accessibility.md ▸ Text scaling: "no fixed
// point sizes"): at a large system text size a 300-point branding column would clip the app
// name it exists to show, and a 760 × 460 floor would leave the recents list too narrow for
// the three lines each row carries. At the standard body size these are those numbers.
.frame(minWidth: Self.minimumWidth, minHeight: Self.minimumHeight)
// The window has no title bar, so the background is the drag handle. `.gesture` rather than
// `.highPriorityGesture`: a click on a row or a button belongs to the row or the button.
.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
private var branding: some View {
VStack(alignment: .leading, spacing: 0) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: Self.appIconSide, height: Self.appIconSide)
.accessibilityHidden(true)
Text("Lanework")
// A **relative** style, not a 34pt literal — "relative text styles everywhere, no
// fixed point sizes" (10-accessibility.md ▸ Text scaling & visual accommodations).
// `.largeTitle` is the app name's register and it grows with the system text size;
// a fixed size would have stayed put while every line beneath it grew past it.
.font(.largeTitle.weight(.light))
.padding(.top, BoardMetrics.em(0.9, bodyPointSize: Self.pointSize))
Text(versionSummary)
.font(.callout)
.foregroundStyle(.secondary)
Text("Folders and Markdown, on your terms.")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(.top, BoardMetrics.em(0.3, bodyPointSize: Self.pointSize))
Spacer(minLength: BoardMetrics.em(1.85, bodyPointSize: Self.pointSize))
VStack(spacing: BoardMetrics.em(0.6, bodyPointSize: Self.pointSize)) {
// 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()
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var versionSummary: String {
let info = Bundle.main.infoDictionary
let short = info?["CFBundleShortVersionString"] as? String ?? "—"
let build = info?["CFBundleVersion"] as? String ?? "—"
return "Version \(short) (\(build))"
}
// MARK: Recents
@ViewBuilder
private var recents: some View {
let derivation = self.derivation
VStack(spacing: 0) {
if derivation.rows.isEmpty {
emptyHint
} else {
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)
.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)
Text(failure.message)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(failure.path)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.middle)
}
.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(BoardMetrics.em(1.25, bodyPointSize: Self.pointSize))
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: Actions
/// 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, BoardMetrics.em(0.15, bodyPointSize: WelcomeView.pointSize))
}
.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's own icon and tint — registry-cached with live write-through (02 §
// Per-board app state: "the row's title and icon are registry-cached too — with live
// write-through"). A record with no override, or one naming a symbol this system
// cannot draw, falls back to the board-default glyph in secondary — the same
// lenient-fallback shape every other icon site in the app uses (`ItemSymbol`,
// `Palette`), applied here to the registry's cached string instead of a live snapshot.
Image(systemName: iconName)
// Relative, like every other size in this window (10-accessibility.md's
// full-relative-scaling rule): `.title` is the register a 22pt glyph occupied at the
// standard text size, and the well around it is derived from the body font so the
// glyph never outgrows it.
.font(.title)
.foregroundStyle(iconTint)
.frame(width: iconWell, height: iconWell)
.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, BoardMetrics.em(0.3, bodyPointSize: WelcomeView.pointSize))
// 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)
}
/// The square the row's glyph sits in — font-derived so the icon column stays proportionate to
/// the three lines of text beside it at every system text size.
private var iconWell: CGFloat {
BoardMetrics.em(2.6, bodyPointSize: BoardMetrics.bodyPointSize)
}
/// `row.icon`'s symbol if it names one this system can draw, the board default otherwise —
/// `ItemSymbol.name(_:fallback:)`'s rule, restated for a plain cached string rather than a
/// `FieldValue`: a record carries no `FieldValue`, so `missing`/`malformed`/`unrecognized`
/// have already folded into one `nil` by the time it reaches here.
private var iconName: String {
guard let icon = row.icon, ItemSymbol.exists(icon) else { return ItemSymbol.board }
return icon
}
/// `row.iconColor`'s tint, or the standard secondary one — `LaneView`'s card-face `iconTint`,
/// same fallback, same reason: an uncoloured icon is chrome, and chrome is secondary.
private var iconTint: AnyShapeStyle {
if let iconColor = row.iconColor, let color = Palette.color(named: iconColor) {
AnyShapeStyle(color)
} else {
AnyShapeStyle(.secondary)
}
}
@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)
}
}
}
// MARK: - Settings
/// The app's preferences (⌘, — 11-command-nexus.md).
///
/// One control, which is the whole of v1: "Restore open boards at launch". The preference gates only
/// whether the registry's open-now flags are *consulted* at launch — the flags themselves are
/// maintained either way, which is what keeps crash recovery working for a user who has restoration
/// turned off and then turns it back on.
struct SettingsView: View {
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey)
private var restoreOpenBoardsAtLaunch = true
var body: some View {
Form {
Section {
Toggle("Restore open boards at launch", isOn: $restoreOpenBoardsAtLaunch)
} footer: {
Text("On, the boards open at your last quit reopen automatically. Off, every launch starts at Welcome.")
}
}
.formStyle(.grouped)
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip its one
// toggle's footer sentence at a large system text size with no way to resize out of it
// (10-accessibility.md ▸ Text scaling).
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
.fixedSize()
}
}