The whole board-name area opens the popover — the chevron alone was the trigger

The titlebar widget grows from a 20×18 chevron into one button saying
the board's name and, on a git-mode Pro board, its branch — click
anywhere across it and the popover opens as before, anchored to the
widget. BoardInfoTitlebarSummary is the pure seam for both strings
(title falls back to the folder name per 01's naming rule; branch only
under pro + git mode, live off the observable HistoryStore.branch).
Board windows now hide the system title display through the same
hideTitle slot card windows adopted — the widget says the name, so the
chrome would only repeat it — while navigationTitle keeps feeding
window.title to the Window menu, Exposé, VoiceOver and restoration.

The widget also refreshes the branch eagerly at appearance: it used to
populate only once the popover had been opened, which would have left
the new branch line empty on a freshly opened board.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-08-01 08:28:53 -04:00
parent 6f2e0d15da
commit 94e60cd444
6 changed files with 253 additions and 34 deletions
+7
View File
@@ -300,6 +300,13 @@ struct BoardWindowHost: View {
presentation: boardInfo presentation: boardInfo
) )
) )
// The widget above now says the board's name (and, on a git-mode Pro board, its branch)
// itself, so the system title display would only repeat it the card-window seam
// (`CardWindowHost.configureWindow`, `HostedWindowController.hideTitle`), applied here for
// the same reason. `.navigationTitle(windowTitle)` a few lines up in `body` is untouched
// `window.title` keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the
// title bar's own rendering of that string is suppressed.
windowController.hideTitle()
// The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the // The board's customizable toolbar (03-board-ui.md Toolbar) installed here for the
// accessory's reason exactly: it carries the store, and it is a board window's, not every // accessory's reason exactly: it carries the store, and it is a board window's, not every
+4 -2
View File
@@ -857,8 +857,10 @@ struct CardWindowHost: View {
// on this view still sets it every time the card renames or a new card's window opens so // on this view still sets it every time the card renames or a new card's window opens so
// the Window menu, Mission Control/Exposé, VoiceOver and state restoration all keep naming // the Window menu, Mission Control/Exposé, VoiceOver and state restoration all keep naming
// this window correctly; only the title *bar's* rendering of that string is suppressed // this window correctly; only the title *bar's* rendering of that string is suppressed
// (`HostedWindowController.hideTitle`). Board windows call no such thing and keep AppKit's // (`HostedWindowController.hideTitle`). Board windows call the same thing now, for the same
// default (`.visible`), which is where their title lives. // reason, once their board-popover widget has a name of its own to say
// (`BoardWindowHost.configureWindow`) only the restore-bootstrap window still keeps
// AppKit's `.visible` default.
windowController.hideTitle() windowController.hideTitle()
// **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31 // **This window's own stack** (13-native-undo.md Rules two levels, re-ruled 2026-07-31
+12 -8
View File
@@ -91,12 +91,14 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// their board has loaded. /// their board has loaded.
private var toolbarController: WindowToolbarController? private var toolbarController: WindowToolbarController?
/// Whether this window's title is hidden from the title bar **card windows only** /// Whether this window's title is hidden from the title bar **card and board windows**: the
/// (05-card-window.md Window: the card's name is shown as part of the card's body, not the /// card's name is shown as part of the card's body instead of the chrome (05-card-window.md
/// chrome). `nil` leaves AppKit's own default (`.visible`) untouched, which is what every board /// Window), and the board's is said by the board-popover widget in the titlebar instead
/// window keeps without a call of its own the same "nothing to do" posture `titlebarAccessory` /// (03-board-ui.md Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own
/// has on welcome, the bootstrap and card windows, mirrored here for the one window kind that /// default (`.visible`) untouched the restore-bootstrap window's posture, the one
/// *does* have an opinion. /// `HostedWindowController`-hosted window with no opinion here, the same "nothing to do" posture
/// `titlebarAccessory` has on welcome (which never attaches a controller at all), the bootstrap
/// window, and now for that slot specifically card windows too.
/// ///
/// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to /// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to
/// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the /// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the
@@ -212,8 +214,10 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
// MARK: Title visibility // MARK: Title visibility
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today /// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
/// the card-window seam (`CardWindowHost`, 05-card-window.md Window). `window.title` is /// the card-window seam (`CardWindowHost`, 05-card-window.md Window) and, since the
/// untouched by this call on purpose; see the property's doc comment for why. /// board-popover widget grew to say the board's name itself, the board-window one too
/// (`BoardWindowHost`, 03-board-ui.md Board popover). `window.title` is untouched by this call
/// on purpose; see the property's doc comment for why.
/// ///
/// Safe to call whenever the caller learns it wants this before the window exists (the value is /// Safe to call whenever the caller learns it wants this before the window exists (the value is
/// held and applied at `attach`) or after (applied immediately) and safe to call more than once, /// held and applied at `attach`) or after (applied immediately) and safe to call more than once,
+122 -18
View File
@@ -56,13 +56,19 @@ extension FocusedValues {
// MARK: - The window-title widget // MARK: - The window-title widget
/// The titlebar widget: a quiet disclosure chevron whose one job is this popover. /// The titlebar widget: the board's name and, on a git-mode Pro board, its branch with a
/// trailing 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 popover is anchored to the widget itself** it hangs from the button rather than from the
/// the window or the board which is what makes the affordance and the surface read as one thing. /// window or the board which is what makes the affordance and the surface read as one thing. A
/// A `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a /// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// click outside dismisses it), and because the content is SwiftUI either way; the AppKit half of /// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// this is only the *placement* (`boardInfoTitlebarAccessory`). /// 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 { struct BoardInfoWidget: View {
let store: BoardStore let store: BoardStore
@@ -71,32 +77,126 @@ struct BoardInfoWidget: View {
/// The tier and the git state this board's **session** composed with read once, at the moment /// 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 /// 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 /// 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. /// flipping the mode, or a branch switch, redraws the widget without anything here being
/// re-created.
let tier: Tier let tier: Tier
let git: HistoryStore? let git: HistoryStore?
@Bindable var presentation: BoardInfoPresentation @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,
tier: tier,
mode: git?.mode ?? .none,
branch: git?.branch
)
}
var body: some View { var body: some View {
Button { Button {
presentation.toggle() presentation.toggle()
} label: { } label: {
Image(systemName: "chevron.down") HStack(spacing: 4) {
.imageScale(.small) Text(summary.title)
.fontWeight(.semibold) // Styled like a titlebar title, because that is what it now stands in for
.foregroundStyle(.secondary) // (`BoardWindowHost` hides the system title display in favor of this widget).
// Sized like a titlebar control rather than by its glyph: the hit target has to be .font(.system(size: 13, weight: .semibold))
// clickable at titlebar scale, where the chevron alone is a few points across. .foregroundStyle(.primary)
.frame(width: 20, height: 18) .lineLimit(1)
.contentShape(Rectangle()) .truncationMode(.tail)
// Yields space to the branch string and chevron first when the two don't both
// fit inside the width cap below the board's own name is the more load-bearing
// half of the pair.
.layoutPriority(1)
if let branch = summary.branch {
Text("")
.foregroundStyle(.secondary)
Text(branch)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
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. Height stays the
// original chevron's, which is what keeps the accessory titlebar-appropriate.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 18)
.contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Board Info") .help("Board Info")
.accessibilityLabel("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) { .popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents, tier: tier, git: git) BoardInfoView(store: store, recents: recents, tier: tier, 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)"
}
}
// 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 one level down in this file.
///
/// **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 under Pro `tier == .pro && mode ==
/// .git` with a non-`nil` branch the same condition family `BoardGitSection.resolve`'s `.branch`
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show 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, tier: Tier, mode: BoardGitMode, branch: String?) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
self.branch = (tier == .pro && mode == .git) ? branch : nil
}
} }
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar: /// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
@@ -128,9 +228,13 @@ func boardInfoTitlebarAccessory(
) )
) )
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself // The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget. // 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.
hosting.sizingOptions = [.intrinsicContentSize] hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18) hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
let controller = NSTitlebarAccessoryViewController() let controller = NSTitlebarAccessoryViewController()
controller.view = hosting controller.view = hosting
+69
View File
@@ -77,3 +77,72 @@ struct BoardGitSectionTests {
#expect(resolved == Set(BoardGitSection.allCases)) #expect(resolved == Set(BoardGitSection.allCases))
} }
} }
/// **The window-title widget's two strings** (03-board-ui.md Board popover, the card that widened
/// the widget from a chevron to the whole board-name area): `BoardInfoTitlebarSummary` is the pure
/// function this pins, exactly as `BoardGitSectionTests` above pins the section it shares its
/// tier/mode inputs with. No disk I/O the title half of this seam takes a raw `URL`, not a
/// `BoardStore`, so a plain `/tmp/...` path is enough.
@Suite("Board popover ▸ the widget's strings")
struct BoardInfoTitlebarSummaryTests {
private let root = URL(fileURLWithPath: "/tmp/My Board.board")
@Test("The title is the on-disk title, falling back to the folder name sans extension")
func titleFallsBackToTheFolderName() {
let named = BoardInfoTitlebarSummary(
snapshotTitle: "Sprint 12", rootURL: root, tier: .free, mode: .none, branch: nil
)
#expect(named.title == "Sprint 12")
let untitled = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .free, mode: .none, branch: nil
)
#expect(untitled.title == "My Board")
let emptyTitled = BoardInfoTitlebarSummary(
snapshotTitle: "", rootURL: root, tier: .free, mode: .none, branch: nil
)
#expect(
emptyTitled.title == "My Board",
"an empty title reads the same as no title at all — the window-title rule this seam restates"
)
}
@Test("The branch shows under Pro, in git mode, once it has been read")
func branchShowsForProGitMode() {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: "main"
)
#expect(summary.branch == "main")
}
@Test("The free tier never shows a branch, whatever the mode or the git state hands it")
func theFreeTierNeverShowsABranch() {
for mode in BoardGitMode.allCases {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .free, mode: mode, branch: "main"
)
#expect(summary.branch == nil, "detection never runs under the free tier, so a stray branch value must never surface")
}
}
@Test("An inert .git under Pro — mode none or repo-nested — never shows a branch")
func inertGitNeverShowsABranch() {
#expect(
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .none, branch: "main").branch == nil
)
#expect(
BoardInfoTitlebarSummary(snapshotTitle: nil, rootURL: root, tier: .pro, mode: .repoNested, branch: "main").branch
== nil
)
}
@Test("A git-mode board whose branch has not been read yet shows none, honestly")
func unreadBranchShowsNone() {
let summary = BoardInfoTitlebarSummary(
snapshotTitle: nil, rootURL: root, tier: .pro, mode: .git, branch: nil
)
#expect(summary.branch == nil)
}
}
+39 -6
View File
@@ -972,14 +972,47 @@ struct BoardInfoAccessoryTests {
#expect(window.titlebarAccessoryViewControllers.count == 1) #expect(window.titlebarAccessoryViewControllers.count == 1)
controller.detach() controller.detach()
} }
/// **The card that widened the widget from a chevron to the whole board-name area also hides the
/// title bar's own title on board windows** (`BoardWindowHost.configureWindow`'s two calls, in the
/// same order: install the widget, then `hideTitle()`) the widget now says the board's name, so
/// the system title display would only repeat it, exactly the card-window seam
/// `CardWindowTitleVisibilityTests` pins below. This test cannot drive `BoardWindowHost.body`
/// itself (it needs a live `AppModel` session), so it pins the two calls composed the same way, in
/// the same order, against a real window which is what would break silently if a later change
/// separated them or reordered them.
@Test("Installing the widget and hiding the title compose without one clobbering the other")
func hidesTitleAlongsideTheAccessory() throws {
let fixture = try makeBoardRoot(richBoardIndex)
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let (recents, teardown) = makeRecents()
defer { teardown() }
let window = makeWindow()
window.title = "My Board"
let controller = HostedWindowController()
controller.attach(to: window)
controller.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
)
controller.hideTitle()
#expect(window.titlebarAccessoryViewControllers.count == 1)
#expect(window.titleVisibility == .hidden, "the widget says the name now; the chrome shouldn't say it twice")
#expect(window.title == "My Board", "the Window menu, Exposé and restoration still need the string")
}
} }
// MARK: - Card window chrome hidden title // MARK: - Card window chrome hidden title
/// **Card windows show no title in the title bar** the card's name is shown as part of the card's /// **Card and board windows show no title in the title bar** a card's name is shown as part of its
/// body instead (05-card-window.md Window: "card title is shown as part of card's body"), while the /// body instead (05-card-window.md Window: "card title is shown as part of card's body"), and a
/// toolbar renders exactly as it always has. `HostedWindowController.hideTitle()` is the seam /// board's is said by the board-popover widget in the titlebar instead (03-board-ui.md Board
/// `CardWindowHost` reaches for; board windows call no such thing and keep AppKit's own default. Like /// popover) while the toolbar renders exactly as it always has on both. `HostedWindowController
/// .hideTitle()` is the seam both `CardWindowHost` and `BoardWindowHost` reach for; only the
/// restore-bootstrap window calls no such thing and keeps AppKit's own default. Like
/// `BoardInfoAccessoryTests` above, this cannot see what a user sees on screen only that the /// `BoardInfoAccessoryTests` above, this cannot see what a user sees on screen only that the
/// mechanism this app owns (`NSWindow.titleVisibility`) lands correctly, survives the macOS 26 /// mechanism this app owns (`NSWindow.titleVisibility`) lands correctly, survives the macOS 26
/// provisional-window swap (`HostedWindowController.detach`'s doc comment chrome held by the /// provisional-window swap (`HostedWindowController.detach`'s doc comment chrome held by the
@@ -1013,8 +1046,8 @@ struct CardWindowTitleVisibilityTests {
#expect(window.title == "Fix login", "the Window menu, Exposé and restoration still need the string") #expect(window.title == "Fix login", "the Window menu, Exposé and restoration still need the string")
} }
@Test("A window nobody calls hideTitle() on keeps AppKit's default — the board window's posture") @Test("A window nobody calls hideTitle() on keeps AppKit's default — the restore-bootstrap window's posture")
func boardWindowsKeepTheDefault() { func unconfiguredWindowsKeepTheDefault() {
let window = makeWindow() let window = makeWindow()
let controller = HostedWindowController() let controller = HostedWindowController()
controller.attach(to: window) controller.attach(to: window)