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() } }