import AppKit import SwiftUI /// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the /// widget in the window's titlebar that opens it. /// /// Two sections, in the design's own order: the board rename and the embedded style editor aimed at /// the board. A third, contextual note joins them only on a board that carries an inert `.git` — see /// `BoardGitNote` for the wording and why base's posture there is a quiet signpost rather than a /// standing section (12-editions.md § Base and `.git`, ruled 2026-07-27, superseding the earlier /// reserved-slot placeholder this file once carried). /// /// ### One home, deliberately /// /// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home /// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board /// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's /// class **C** — "the keyboard path is reachability … not bindings"). // MARK: - Presentation state /// Whether **this window's** board popover is open. /// /// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in /// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`) /// exactly as the store does. Two board windows each hold their own and can never toggle each /// other's — which a flag on the store could not promise the day a board is allowed two windows. /// /// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and /// shared with the board's card windows, and a popover living in one window's titlebar is not that. @MainActor @Observable final class BoardInfoPresentation { var isPresented = false /// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a /// disclosure that could only ever open would leave the popover with no keyboard way out. func toggle() { isPresented.toggle() } } /// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why /// board-window menu items reach their window this way rather than through the app model. struct FocusedBoardInfoKey: FocusedValueKey { typealias Value = BoardInfoPresentation } extension FocusedValues { var boardInfo: BoardInfoPresentation? { get { self[FocusedBoardInfoKey.self] } set { self[FocusedBoardInfoKey.self] = newValue } } } // MARK: - The window-title widget /// The titlebar widget: a quiet disclosure chevron whose one job is this popover. /// /// **The popover is anchored to the widget itself** — it hangs from the chevron rather than from /// the window or the board — which is what makes the affordance and the surface read as one thing. /// A `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a /// click outside dismisses it), and because the content is SwiftUI either way; the AppKit half of /// this is only the *placement* (`boardInfoTitlebarAccessory`). struct BoardInfoWidget: View { let store: BoardStore let recents: StyleRecents /// The tier and the git state this board's **session** composed with — read once, at the moment /// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse /// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git /// flipping the mode redraws the popover without anything here being re-created. let tier: Tier let git: HistoryStore? @Bindable var presentation: BoardInfoPresentation var body: some View { Button { presentation.toggle() } label: { Image(systemName: "chevron.down") .imageScale(.small) .fontWeight(.semibold) .foregroundStyle(.secondary) // Sized like a titlebar control rather than by its glyph: the hit target has to be // clickable at titlebar scale, where the chevron alone is a few points across. .frame(width: 20, height: 18) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Board Info") .accessibilityLabel("Board Info") .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { BoardInfoView(store: store, recents: recents, tier: tier, git: git) } } } /// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar: /// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the /// title bar beside the window title rather than in the window's content. /// /// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per /// window, removed on detach — for the same reason it owns the delegate proxying: the window is /// SwiftUI's, and anything hung on it has to be taken back off. @MainActor /// `tier`/`git` default to the free tier's posture — a popover with no git section at all — so that /// a caller with no session in hand (the accessory-installation tests, which are about AppKit /// plumbing rather than about git) describes a board honestly rather than by accident. The app's own /// call site passes the session's values explicitly. func boardInfoTitlebarAccessory( store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil, presentation: BoardInfoPresentation ) -> NSTitlebarAccessoryViewController { let hosting = NSHostingView( rootView: BoardInfoWidget( store: store, recents: recents, tier: tier, git: git, presentation: presentation ) ) // The titlebar lays its accessories out by fitting size, and a hosting view that measured itself // as zero would be an invisible, unclickable widget. hosting.sizingOptions = [.intrinsicContentSize] hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18) let controller = NSTitlebarAccessoryViewController() controller.view = hosting controller.layoutAttribute = .leading return controller } // MARK: - The popover's content /// Two sections and, on a `.git`-bearing board, a contextual note — one view (03-board-ui.md § /// Board popover; 12-editions.md § Base and `.git`). /// /// Width is the style editor's — 268 points, the number that keeps the Style… popover narrow enough /// to sit beside a card — so the embedded editor lays out here exactly as it does at its other two /// anchors rather than being stretched by a container with its own opinion. struct BoardInfoView: View { let store: BoardStore let recents: StyleRecents let tier: Tier let git: HistoryStore? /// Whether this board carries a `.git` — checked once, off disk, when the view is built (which /// is every time the popover opens, since `BoardInfoWidget` hands `.popover` a fresh instance). /// See `BoardGitNote.hasGitDirectory(at:)` for why a live-updating fact isn't needed here. /// /// **The free tier's input only.** Under Pro the section reads the session's detected mode /// instead — a fact settled at open, which is where 06-history-undo.md puts detection — and this /// stays what it always was: the one quiet question the free tier asks of a board's folder. private let hasGitDirectory: Bool /// The style editor brings its own padding, so the sections around it carry the same number by /// hand instead of an outer padding that would double up on it — **the editor's own figure** /// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales /// with the grids inside it (10-accessibility.md's full-relative-scaling rule). private var inset: CGFloat { StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize) } init(store: BoardStore, recents: StyleRecents, tier: Tier = .free, git: HistoryStore? = nil) { self.store = store self.recents = recents self.tier = tier self.git = git // Asked only where it is the answer: under Pro the mode already knows, and a free-tier // board is the only one this question is for (12-editions.md ▸ The free tier and `.git`). self.hasGitDirectory = tier == .free && BoardGitNote.hasGitDirectory(at: store.rootURL) } var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 6) { sectionHeader("Title") BoardRenameField(store: store) } .padding(inset) Divider() VStack(alignment: .leading, spacing: 0) { sectionHeader("Styling") .padding(.horizontal, inset) .padding(.top, inset) // Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one // ("nothing selected = the board"); this embed is the surface that exists *because* // the board is a style target, so it can have no other target (§ Styling ▸ // Controls: "the board popover's target is the board itself"). StyleEditorView(store: store, recents: recents, target: .board) } // Contextual, not standing (12-editions.md, settled 2026-07-27): an ordinary free-tier // board adds nothing here at all — no header, no divider, no placeholder — and the // popover ends at Styling, complete in itself. What a Pro board adds instead is the // mode-aware git section (03-board-ui.md ▸ Board popover), which is a *section*, header // and all, because under Pro git is a feature of the board rather than a signpost. gitSection } // The style editor's popover width, taken from the editor rather than restated: the embed // below must lay out here exactly as it does at its other two anchors, and that number is // now font-derived. .frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width) } /// The popover's closing section, whichever of the five postures this board is in — see /// `BoardGitSection`. @ViewBuilder private var gitSection: some View { switch BoardGitSection.resolve(tier: tier, mode: git?.mode ?? .none, hasGitDirectory: hasGitDirectory) { case .absent: EmptyView() case .proPointer: Divider() BoardGitNote() .padding(inset) case .addGit: Divider() VStack(alignment: .leading, spacing: 6) { sectionHeader("Git") if let git { BoardGitAddAction(git: git, isEnabled: store.acceptsBoardMutations) } } .padding(inset) case .repoNested: Divider() VStack(alignment: .leading, spacing: 6) { sectionHeader("Git") BoardGitNestedNote() } .padding(inset) case .branch: Divider() VStack(alignment: .leading, spacing: 6) { sectionHeader("Git") if let git { BoardGitControls(git: git, isEnabled: store.acceptsBoardMutations) } } .padding(inset) } } /// The section titles, matching the style editor's own headers so the popover reads as one /// surface rather than borrowed ones. private func sectionHeader(_ title: String) -> some View { Text(title) .font(.subheadline.weight(.semibold)) } } // MARK: - Rename /// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming). /// /// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the /// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the /// folder name, because that is what the window title will say, and committing empty is how a user /// asks for exactly that. "Untitled" appears nowhere; boards do not have it. /// /// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture /// that means "edit this now"; this one is one control among several on a configuration surface, /// where the keyboard path is Tab-reachability rather than a caret waiting in the first field /// (11-command-nexus.md's class **C**). /// /// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction — /// Return commits and then something takes focus, which fires the focus-loss commit an instant /// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing. private struct BoardRenameField: View { let store: BoardStore @State private var draft = "" @FocusState private var isFocused: Bool var body: some View { TextField(fallbackName, text: $draft) .textFieldStyle(.roundedBorder) .lineLimit(1) .focused($isFocused) .onSubmit { store.renameBoard(draft) } // **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty // field abandons its edit and keeps the popover open, and an unedited one lets the press // through to the popover's own dismissal. Reverting first is also what makes a dismissal // safe on the paths where the press never reaches here — the focus-loss commit that // follows sees a draft equal to what is on disk and writes nothing. .onKeyPress(.escape) { guard draft != committed else { return .ignored } draft = committed return .handled } .onChange(of: isFocused) { _, focused in guard !focused else { return } store.renameBoard(draft) } .onAppear { draft = committed } // A foreign rename — an agent, a hand edit, a sync — landing behind an open popover // updates the field, but never under the user's fingers: a draft being typed is the // user's, and the reload is not an edit to it (02-architecture.md § Live-reload // resilience, the same courtesy the inline editors get by tracking their UUID). .onChange(of: committed) { _, title in guard !isFocused else { return } draft = title } // The popover can be dismissed without the field ever reporting focus loss, and a // dismissal is a commit like any other click-away. Idempotent with the handler above. .onDisappear { store.renameBoard(draft) } // The read-only lock disables every mutating surface (02-architecture.md § The lock's // scope) — and the popover *stays open* under it, which is the style popover's settled // precedent: the lock is a condition the banner is already explaining, not a reason to // yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this // field and the editor below it disable as one surface rather than in halves. .disabled(!store.acceptsBoardMutations) } /// The `title` on disk, as the last reload read it — "" for a board that has none. private var committed: String { store.snapshot.title.value ?? "" } /// The folder name, sans extension: what the window title shows when `title` is absent /// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to /// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed /// mid-session shows here immediately. private var fallbackName: String { store.rootURL.deletingPathExtension().lastPathComponent } } // MARK: - Git /// **What the popover's git slot is, for one board** (03-board-ui.md ▸ Board popover; /// 06-history-undo.md ▸ Rules; 12-editions.md ▸ The free tier and `.git`) — a pure function of two /// facts, so the posture matrix is provable without a popover on screen. /// /// The free tier's two cases are settled 2026-07-27 and unchanged by this card: absent on an /// ordinary board, a one-line Pro pointer on a board carrying an inert `.git`. The Pro cases are the /// mode, one to one — and the mode-`none` and repo-nested pair is where the design is most /// insistent: a repo-nested board gets **prose, not a disabled button**. "The option is absent /// because it *can't* apply, and the UI should teach that rather than look broken" (06 ▸ Rules). enum BoardGitSection: Equatable, CaseIterable { /// Nothing at all — the free tier's ordinary board, where "the popover is rename + style, /// complete in itself". case absent /// The free tier's one-line explanation of an inert `.git`, and the app's one in-context pointer /// to Pro (12 ▸ Tier naming). case proPointer /// Pro, mode `none`: the add-git action (06 ▸ Rules ▸ Opt-in init). case addGit /// Pro, repo-nested: the honest explanation, no action. case repoNested /// Pro, git mode: the branch/source line with switching and creation, the abnormal-state /// explanation when the surface is held, and the commit-identity fields (`BoardGitControls`). /// The remote half — tracking, Pull/Push, push-on-commit, authentication — is 07-sync-collab.md's /// own card and joins this same posture. case branch static func resolve(tier: Tier, mode: BoardGitMode, hasGitDirectory: Bool) -> BoardGitSection { switch tier { case .free: // Detection never runs under the free tier, so the mode is not consulted here — the one // question asked is whether the folder carries a `.git`, which is what the pointer is // about (12: "any `.git` is inert … a stray like any other, preserved verbatim"). return hasGitDirectory ? .proPointer : .absent case .pro: switch mode { case .none: return .addGit case .git: return .branch case .repoNested: return .repoNested } } } } /// **The add-git action** (06-history-undo.md ▸ Rules ▸ Opt-in init) — the one place in the app that /// creates a repository, and the reason "no silent auto-init, ever" is a checkable claim rather than /// a promise: there is no other caller of `HistoryStore.addGit`. /// /// The caption states what pressing it does, in the order it happens, because it is not undoable in /// the ordinary sense: a repository appears in the board's folder and its current state becomes the /// first commit. private struct BoardGitAddAction: View { let git: HistoryStore /// The read-only lock's reach (02-architecture.md ▸ The lock's scope): a board that refuses /// writes refuses this one too — initializing a repository is a write, and a commit is several. let isEnabled: Bool var body: some View { VStack(alignment: .leading, spacing: 6) { Button("Add Git") { Task { await git.addGit() } } .disabled(!isEnabled || git.isAddingGit) Text("Creates a git repository in this board's folder and commits its current state.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) if let failure = git.lastFailure { Text(failure.message) .font(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) } } } } /// **The repo-nested explanation** (06-history-undo.md ▸ Rules), worded as the design words it: /// short prose in place of an action, never a hidden or greyed-out add-git. private struct BoardGitNestedNote: View { var body: some View { Text("This board lives inside a repository; Lanework leaves it to that repository.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } /// The contextual git note — **a quiet signpost, not a feature** (12-editions.md ▸ The free tier and /// `.git`, settled 2026-07-27, carried through the one-app collapse). The free tier has no git /// integration (that is the Pro subscription's), so this is not a grow-in-place slot the way the old /// `BoardGitSlot` placeholder was: there is nothing here to grow. The free tier's whole git story is /// one line, shown only when it is true — and it is **the one in-context pointer to Pro**, the second /// of the three places the app names it (12 ▸ Tier naming; the other two are `AboutBox` and the /// Settings Pro section). /// /// On an ordinary board `BoardInfoView` never instantiates this type at all — the section is /// *absent*, matching the card window's absent History section (12: "absent, no placeholder"). Only /// a board that carries an inert `.git` (12 ▸ the inert posture: "any `.git` is inert" — the free /// tier never reads or writes it, whether the board's own or one a lapsed subscription left behind) /// earns this note, worded exactly as 12 rules: an honest explanation of what the folder is, named /// exactly where the question arises, never a standing ad for Pro. /// /// Not `private`: `hasGitDirectory(at:)` is the pure seam `BoardInfoPopoverTests.swift` pins directly /// (a fixture board with `.git` → true, without → false), which needs it visible past this file even /// though nothing outside `BoardInfoPopover.swift` calls it in the app itself. struct BoardGitNote: View { var body: some View { Text("This board has a git history. Lanework Pro works with it.") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } /// Whether `boardRoot` carries a `.git` entry — the free tier's entire detection story, and a deliberately /// small one: nothing in `BoardStore`, `BoardModel`, or `BoardLoader` tracks this as a live fact /// today, because nothing needs it to be live. `FolderWatcher` filters `.git` out of the folder /// watch by design (§ .git filtering — it exists to ignore git churn), so there is no reload /// event this could hang off even if it wanted to; a plain, read-only `FileManager` check taken /// once, at the moment the popover is built, is the honest amount of machinery for a single quiet /// line. A `.git` added or removed while the popover happens to be open is stale until the next /// open — a gap this note's own posture makes harmless, since it is a signpost, not a control. static func hasGitDirectory(at boardRoot: URL) -> Bool { FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path) } }