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. /// /// **Tabbed since 2026-08-07 — the restructure is complete, all three tab sessions settled.** The /// symbol/name header stays at the top; below it sit the tabs — **Info**, **Theme**, **Git** — each /// the settings surface for one aspect of board configuration, each settled in its own dedicated /// design session: `BoardInfoTabView`, the metrics dossier; `BoardThemeTabView`, the Solid color / /// Pattern picker (the Background tab's original name, before the same session widened it past the /// generated-only picker and folded manual styling back out to Style… ⌥⌘S); and `BoardGitTabView`, /// which the pre-tab body's mode-aware git section rehomed into whole — postures and notes, none of /// them re-ruled by the move. That tab carried a **Board Settings…** row to a separate sheet until /// later the same day, when the 2026-07-31 popover/sheet split was reversed and the sheet's contents /// rehomed into the tab (see below). /// /// **Tab membership is the git posture's**, and **selection resets to Info on every open** — both /// the Git session's rulings. Since 12-editions.md ▸ PIVOT 2026-08-07 the first of those is a /// structural rule with nothing left to exclude: every board carries all three tabs (03 ▸ Board /// popover, the same-day pivot note) — see `BoardInfoTab`. /// /// ### 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"). /// /// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31 /// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and /// a row here pointing at it. The sheet is retired, its add-git and commit-identity controls render /// inline in the Git tab's postures (`BoardGitSetup.swift`), branch creation went back into the /// switch menu (`BoardGitControls`), and Board ▸ Board Settings… left the menu bar with them. So /// "one home per control" — the split's own promise — is now satisfied by there being one surface, /// and ⌘I is the door to all of it. // 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: the board's glyph beside a two-line identity block — its name, and on a /// git-mode board its branch under the name — with a trailing disclosure chevron, whose one job is /// this popover. /// /// **A two-line stack since 2026-08-07** (03-board-ui.md ▸ Board popover, the window-title widget /// passage). It was one line reading `glyph Title — branch ⌄`, and the em-dash was the tell: a /// separator doing a *hierarchy's* job, with the branch competing for the same width as the name it /// qualifies. So the branch moved under the title in its own smaller, secondary line, the em-dash /// retired, and the glyph grew to span both lines — an icon sized to the block it labels rather than /// to whichever line it happened to sit on. A board with no branch is the single title line, vertically /// centred beside the same glyph, which is the same block with one row. /// /// **The popover is anchored to the widget itself** — it hangs from the button 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`). /// /// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron /// button to the full name/branch/chevron button): the title and branch strings sit inside the same /// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the /// popover exactly as a click on the chevron always has. struct BoardInfoWidget: View { let store: BoardStore let recents: StyleRecents /// 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, /// or a branch switch, redraws the widget without anything here being re-created. /// /// It stopped being a tier signal at 12 ▸ PIVOT 2026-08-07 — every session composes a git state /// now, whatever the tier — so a `nil` here means only "this caller had no session to hand over" /// (previews, the accessory-installation tests), which reads as mode `none`. let git: HistoryStore? @Bindable var presentation: BoardInfoPresentation /// The widget's two strings, computed fresh on every body evaluation rather than cached anywhere. /// That matters here specifically: `boardInfoTitlebarAccessory` builds this view exactly **once** /// at install, so a value read anywhere but inside `body` would freeze at the widget's birth and /// never see a later rename or branch switch. `store.snapshot` and `git.branch` are both /// `@Observable`, so reading them here is what makes the title and branch live. private var summary: BoardInfoTitlebarSummary { BoardInfoTitlebarSummary( snapshotTitle: store.snapshot.title.value, rootURL: store.rootURL, mode: git?.mode ?? .none, branch: git?.branch ) } var body: some View { Button { presentation.toggle() } label: { HStack(spacing: 6) { // The board's own glyph beside its name — the identity pair the popover header // states, restated where the board is named all day (2026-08-07). Read inside // `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a // restyle from the popover repaints this widget without reinstalling it. Lenient on // both dimensions — an unresolvable glyph draws the board default, an unresolvable // tint draws the standard secondary. // // **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt // it draws about twice the height `.imageScale(.small)` gave it on the container's // 13pt, which is what lets one glyph span the title and branch lines instead of // sitting beside the upper one. The number is the icon's own size rather than a // scale factor because that is the dimension being chosen — the block's height. Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board)) .font(.system(size: 22)) .foregroundStyle(iconTint) // Decorative beside the name it repeats — the button's own label already says // everything VoiceOver needs (`accessibilityLabel` below). .accessibilityHidden(true) // The identity block: the name, and the branch beneath it on a git-mode board. On // any other board this is the single title line and the `HStack`'s own centring puts // it level with the glyph — the stack is the same shape with one row, never a // special case. VStack(alignment: .leading, spacing: 1) { Text(summary.title) // Styled like a titlebar title, because that is what it now stands in for // (`BoardWindowHost` hides the system title display in favor of this widget). .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) if let branch = summary.branch { // Smaller and secondary — the qualifier under the name it qualifies. The // em-dash that separated the two on one line retired with the stack: a // hierarchy that a layout can state does not need punctuation to state it. Text(branch) .font(.system(size: 11)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) } } // Yields space to the chevron first when the block doesn't fit inside the width cap // below — the board's identity is the more load-bearing half of the pair, and both // its lines truncate rather than the disclosure disappearing. .layoutPriority(1) Image(systemName: "chevron.down") .imageScale(.small) .fontWeight(.semibold) .foregroundStyle(.secondary) } .font(.system(size: 13)) // A long board name (or branch) must not swallow the whole titlebar — capped rather // than left to grow, with the truncation above doing the rest. The height is the // two-line block's (2026-08-07; it was the original chevron's 18 while the widget was // one line), which is what keeps the accessory titlebar-appropriate: tall enough for // name-over-branch, and no taller than a standard title bar carries. .frame(maxWidth: 400, alignment: .leading) .frame(height: 32) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Board Info") .accessibilityLabel(accessibilityLabel) .accessibilityHint("Shows board info") // Populates the branch line the moment a git-mode board's window opens, rather than waiting // on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover // has actually been opened once). The widget is on screen from the start, so it is the // earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this // costs nothing on the other four postures. .task { await git?.refreshBranch() } .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) { BoardInfoView(store: store, recents: recents, git: git) } } /// What VoiceOver reads for the button, now that it says more than "Board Info": the board's /// name, plus the branch when the widget is showing one — `.help` keeps the shorter "Board Info" /// wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names what /// the button does. private var accessibilityLabel: String { guard let branch = summary.branch else { return summary.title } return "\(summary.title), branch \(branch)" } /// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary /// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level. private var iconTint: AnyShapeStyle { if let color = Palette.color(for: store.snapshot.iconColor) { return AnyShapeStyle(color) } return AnyShapeStyle(.secondary) } } // MARK: - The widget's strings /// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its /// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility /// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same /// reason `BoardGitSection.resolve` exists over in `BoardGitTabView.swift`. /// /// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title /// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window /// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now /// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder /// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here /// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable /// with plain values and no fixture board on disk — the one duplication this card leaves behind /// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites. /// /// **Branch.** Shown only when the board is actually git-mode — `mode == .git` with a non-`nil` /// branch — the same condition `BoardGitSection.resolve`'s `.branch` case covers. **The tier clause /// is gone** (12-editions.md ▸ PIVOT 2026-08-07: git is tier-independent, so a git-mode board is a /// git-mode board and the widget says so on every tier); the rule it read `tier == .pro && mode == /// .git` until that day is recorded here rather than restated in code. A board with no repository or /// an inert one (mode `.none`, `.repoNested`, `.unverifiable`) shows no branch; neither does a /// git-mode board whose branch has not been read yet (`HistoryStore.branch` starts `nil` until /// `refreshBranch()` answers, which the widget's own `.task` kicks off at open). struct BoardInfoTitlebarSummary: Equatable { let title: String let branch: String? init(snapshotTitle: String?, rootURL: URL, mode: BoardGitMode, branch: String?) { if let snapshotTitle, !snapshotTitle.isEmpty { self.title = snapshotTitle } else { self.title = rootURL.deletingPathExtension().lastPathComponent } self.branch = mode == .git ? branch : nil } } /// 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 /// `git` defaults to no session, so that a caller with none in hand (the accessory-installation /// tests, which are about AppKit plumbing rather than about git) describes a board honestly rather /// than by accident: a Git tab in its no-repository posture, and a widget with no branch line. The /// app's own call site passes the session's value explicitly. func boardInfoTitlebarAccessory( store: BoardStore, recents: StyleRecents, git: HistoryStore? = nil, presentation: BoardInfoPresentation ) -> NSTitlebarAccessoryViewController { let hosting = NSHostingView( rootView: BoardInfoWidget( store: store, recents: recents, 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. `.intrinsicContentSize` re-measures on every // SwiftUI update, so this starting frame only has to survive the first layout pass before the // widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder // (the old chevron-only width) would clamp now that the widget's content can run out to 400pt: // wide enough that the widest realistic first paint is never visibly clipped before the resize. // The height is the widget's own two-line figure (2026-08-07), for the same reason the width is // generous — a first pass clamped to the old one-line 18 would paint a clipped block. hosting.sizingOptions = [.intrinsicContentSize] hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32) let controller = NSTitlebarAccessoryViewController() controller.view = hosting controller.layoutAttribute = .leading return controller } // MARK: - Tabs /// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab /// restructure — all three original sessions settled): **Info** (`BoardInfoTabView`), **Theme** /// (`BoardThemeTabView`), **Git** (`BoardGitTabView`) — and **Sync** (`BoardSyncTabView`), added /// 2026-08-07 as a standing placeholder: the strip claims the position now, the surface says /// honestly that nothing lives there yet, and 07-sync-collab.md's cards are where its contents get /// ruled. The raw values are the segmented control's own labels, so the strip needs no separate /// label function. enum BoardInfoTab: String, CaseIterable, Identifiable { case info = "Info" case theme = "Theme" case git = "Git" case sync = "Sync" var id: Self { self } // **Membership is the git posture's, and the posture never says "absent" any more.** The Git // session ruled (2026-08-07) that the Git tab joins the strip only where `BoardGitSection` has // something true to say, which then meant dropping it on a free board with no `.git` — the // "absent, no placeholder" rule carried up to the strip. **12-editions.md ▸ PIVOT 2026-08-07**, // the same day, retired the free-only postures wholesale: git left the paywall, `.absent` and // `.proPointer` died with it, and 03-board-ui.md ▸ Board popover records the consequence — "the // absent posture is unreachable and every board carries all three tabs. The membership rule // stands structurally — the strip still asks the posture — it just never hears 'absent' // anymore." So there is no `available(…)` filter here to ask it with: membership is `allCases`, // in `allCases`' own order (Info, Theme, Git, Sync), which is what the filter answered anyway. } // MARK: - The popover's content /// The symbol/name header, then the tab bar, then the selected tab's surface — one view /// (03-board-ui.md § Board popover). /// /// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit /// beside a card — kept through the restructure so the popover's footprint didn't wander while the /// tabs filled in; all three settled tabs (Info, Theme, Git) kept it, so whether the tabbed surface /// ever wants its own width remains open, but nothing has needed one yet. struct BoardInfoView: View { let store: BoardStore let recents: StyleRecents let git: HistoryStore? /// The selected tab, and **it resets to Info on every open** — a ruling, not an accident (the /// Git session, 2026-08-07, closing the question the earlier tab sessions deferred): the popover /// is transient and Info is the board's face, and a remembered tab could strand selection on a /// tab the next board's posture doesn't offer. `@State` on the popover's content, which /// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more. @State private var tab: BoardInfoTab = .info /// 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, git: HistoryStore? = nil ) { self.store = store self.recents = recents self.git = git } var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { // The board's own icon, inline with its name — the same field the embedded style // editor's symbol section below writes, offered here too since a board's identity // is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No // `undo:` — the board popover has none of its own, so this reaches the board's // stack exactly as the embedded editor's writes do. SymbolPicker( current: store.snapshot.icon.value, fallback: ItemSymbol.board, onSelect: { name in StyleCommand.apply( icon: name.map { StyleChange.set($0) } ?? .remove, to: .board, in: store, recents: recents ) }, // The colour row — the picker's 4×2 tint grid, writing `iconColor` through // the same funnel the glyph writes `icon`: None removes the key, a well // writes the palette name (2026-08-07). currentColor: store.snapshot.iconColor.value, onSelectColor: { name in StyleCommand.apply( iconColor: name.map { StyleChange.set($0) } ?? .remove, to: .board, in: store, recents: recents ) } ) .disabled(!store.acceptsBoardMutations) BoardRenameField(store: store) } } .padding(inset) Divider() // The tab bar: a segmented control rather than a `TabView`, because the popover is a // compact settings surface and the segmented idiom is the macOS shape for switching // between a handful of peer panes inside one. The label is hidden visually but stays // the control's accessibility name. It iterates `allCases` — the strip's membership is // still the board's git posture in principle (the Git session's ruling), and since // 12-editions.md ▸ PIVOT 2026-08-07 that posture is never "absent", so every board // carries the whole strip (03-board-ui.md ▸ Board popover, the same-day pivot note). Picker("Board configuration", selection: $tab) { ForEach(BoardInfoTab.allCases) { tab in Text(tab.rawValue) } } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, inset) .padding(.top, inset) // The selected tab's surface — the first three settled 2026-08-07, each in its own // dedicated session and its own file; Sync is that day's standing placeholder. Each pads // itself by `inset`, so the switch adds nothing. switch tab { case .info: BoardInfoTabView(store: store, inset: inset) case .theme: BoardThemeTabView(store: store, inset: inset) case .git: BoardGitTabView(store: store, git: git, inset: inset) case .sync: BoardSyncTabView(inset: inset) } } // The style editor's popover width, taken from the editor rather than restated — the number // that keeps a compact settings popover narrow enough to sit beside a card. It is // font-derived, so the whole surface scales with the grids inside it (10-accessibility.md). .frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width) } } // 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 } }