Phase 3 of the one-app pivot (DESIGN 12 ▸ The entitlement / Distribution, ruled 2026-07-30; card c3a3ddd5). New Kanban/Tier/: Tier (.free/.pro — deliberately no .lapsed case; unsubscribed and lapsed are one state) and the pure decision Tier.resolve(from:now:) over SubscriptionFacts (expiration + willAutoRenew), unit-tested through all five named states: free, active, lapsed, offline-grace, never-online. The facts are a persisted cache (standard defaults), not a live view: StoreKit ages an expired subscription out of currentEntitlements locally, so an offline device and a real lapse are indistinguishable from that property alone — the cache holds the last answer, empty entitlements read as silence, and holds end only on a definitive answer (revocation, or the subscription-group status read Settings performs). That is 12's offline-grace trade, resolved toward the paying user. ProEntitlement is the local adapter (currentEntitlements + Transaction.updates, started from launch, never from a test host); ProStorefront holds everything networked (product load, purchase, AppStore.sync) and only the Settings section ever constructs one — the split is the enforcement of "never network on the open path". beginSession reads the tier once at composition; BoardSession.tier is a let with no path back in, so a lapse never rebinds an open session. makeHistoryProvider now takes the tier; both tiers bind the native stack until pro-m1 builds the git provider — the seam's consumer is named, not invented early. Settings gains the Pro section (subscribe with localized price, manage, restore; a quiet unreachable line, no indefinite spinner) — the third of the exactly-three Pro mentions; the About line gains its "…in Settings" pointer now that there is a Settings to point at. A successful purchase or restore offers once to reopen open boards (close + reopen through the ordinary paths). Configuration.storekit wired into the scheme's run action for ASC-free exercise; RELEASE.md gains the pro-m1 store-side steps and the rule that the product must not be configured before then. 1901 tests in 319 suites green. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
414 lines
18 KiB
Swift
414 lines
18 KiB
Swift
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).
|
||
///
|
||
/// Two sections. The first is the one control v1 shipped with: "Restore open boards at launch",
|
||
/// whose 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.
|
||
///
|
||
/// The second is **where Lanework Pro lives** (12-editions.md ▸ Distribution: "purchased and managed
|
||
/// in a Pro section of Settings (⌘,) — subscribe, manage, restore purchases"), and the third of the
|
||
/// exactly three places the app names Pro at all — see `ProSettingsSection` for its own posture.
|
||
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.")
|
||
}
|
||
|
||
ProSettingsSection()
|
||
}
|
||
.formStyle(.grouped)
|
||
// Font-derived: this pane is `.fixedSize()`, so a 420-point literal would clip a footer
|
||
// sentence at a large system text size with no way to resize out of it
|
||
// (10-accessibility.md ▸ Text scaling). The height stays free — the Pro section's own height
|
||
// depends on which of its three states it is in, and pinning it would clip the longest.
|
||
.frame(width: BoardMetrics.em(32.3, bodyPointSize: BoardMetrics.bodyPointSize))
|
||
.fixedSize()
|
||
}
|
||
}
|