Files
lanework/Kanban/App/WelcomeView.swift
T
rzen 566deab506 Home app-side state in the shared App Group container
Every edition declares group.dev.rzen.indie.Kanban and homes its
app-side state there from day one (12-editions.md ruling 2026-07-29):

- AppGroup namespace: container resolution with per-edition fallback
  when unprovisioned, shared UserDefaults suite, edition identity, and
  a unit-test-host redirect (the test host IS the app — its launch
  sweep and recents refresh must not touch the real shared container).
- BoardRecord: bookmark/isOpenNow replaced by per-edition grants and
  openNow keyed by bundle id; hand-written Codable keeps legacy keys
  decoding (adopted in memory as the running edition's slots, upgraded
  on first save); every other field stays common.
- RecentBoard gains needsReopen: no grant of ours but somebody's —
  first click runs an open panel pre-anchored at the recorded path,
  prompt "Grant"; recordOpen mints this edition's slot onto the
  matched shared record (path fallback only after identity fails and
  only against records holding no grant of ours, so re-granting never
  forks the record).
- Cross-edition freshness: stat-cheap mtime+size stamp re-reads the
  registry when the sibling edition wrote it, so one edition's save
  never erases the other's records wholesale.
- restorables() filters on this edition's open-now flags; the board
  popover gains BoardEditionPresence ("Also open in Lanework Pro"),
  pid-liveness-checked so crash residue never lies.
- Clipboard staging store moves to the group container; the sweep
  claims doomed trees by atomic rename into .sweeping/ then deletes,
  so the sibling's concurrent sweep is a non-event.
- Template store re-homed to the group container per the 09-templates
  re-ruling; scalars (quick-style recents, window size) move to the
  shared suite.
- verify-editions.sh: 30 checks (each edition carries exactly the
  family group). No pathfinder 1.x migrator: 1.x predates the
  registry; state starts fresh in the group container.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 20:18:15 -04:00

420 lines
19 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 — through `AppModel.open(_:)`, which owns the two ways a row can lead to
/// one (an available URL, or the re-grant panel a cross-edition row needs first). 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) {
appModel.open(row)
}
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). A cross-edition row is *not* dimmed: it opens on one
// click like any other, and dimming it would advertise a loss that has not happened.
.opacity(row.canOpen ? 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 .needsReopen:
// Not a warning tone: nothing is wrong and nothing is lost — this board came from the
// other edition's list and needs one grant (12-editions.md ▸ Distribution). The words say
// what the click will do, since the click is the whole remedy.
Label("Open once to grant access", systemImage: "hand.raised")
.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 {
/// `store:` named explicitly, and it has to be: the key lives in the group's shared suite
/// (`AppPreferences`), and `@AppStorage`'s default domain is `.standard` — a different one. A
/// toggle bound to the wrong domain would write a preference the launch flow never reads.
@AppStorage(AppPreferences.restoreOpenBoardsAtLaunchKey, store: AppGroup.defaults)
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()
}
}