diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 8f8db3b..02ebafb 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -300,6 +300,13 @@ struct BoardWindowHost: View { 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 // accessory's reason exactly: it carries the store, and it is a board window's, not every diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index a196051..1f19cce 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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 // 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 - // (`HostedWindowController.hideTitle`). Board windows call no such thing and keep AppKit's - // default (`.visible`), which is where their title lives. + // (`HostedWindowController.hideTitle`). Board windows call the same thing now, for the same + // 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() // **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 — diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index 85e49f6..e40f31f 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -91,12 +91,14 @@ final class HostedWindowController: NSObject, NSWindowDelegate { /// their board has loaded. private var toolbarController: WindowToolbarController? - /// Whether this window's title is hidden from the title bar — **card windows only** - /// (05-card-window.md ▸ Window: the card's name is shown as part of the card's body, not the - /// chrome). `nil` leaves AppKit's own default (`.visible`) untouched, which is what every board - /// window keeps without a call of its own — the same "nothing to do" posture `titlebarAccessory` - /// has on welcome, the bootstrap and card windows, mirrored here for the one window kind that - /// *does* have an opinion. + /// Whether this window's title is hidden from the title bar — **card and board windows**: the + /// card's name is shown as part of the card's body instead of the chrome (05-card-window.md ▸ + /// Window), and the board's is said by the board-popover widget in the titlebar instead + /// (03-board-ui.md ▸ Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own + /// default (`.visible`) untouched — the restore-bootstrap window's posture, the one + /// `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 /// 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 /// 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 - /// untouched by this call on purpose; see the property's doc comment for why. + /// — the card-window seam (`CardWindowHost`, 05-card-window.md ▸ Window) and, since the + /// 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 /// held and applied at `attach`) or after (applied immediately) — and safe to call more than once, diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index e37cf54..f8e84f6 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -56,13 +56,19 @@ extension FocusedValues { // 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 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`). +/// **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 @@ -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 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. + /// flipping the mode, or a branch switch, redraws the widget without anything here being + /// re-created. let tier: Tier 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, + tier: tier, + mode: git?.mode ?? .none, + branch: git?.branch + ) + } + 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()) + HStack(spacing: 4) { + 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) + // 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) .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) { 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: @@ -128,9 +228,13 @@ func boardInfoTitlebarAccessory( ) ) // 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.frame = NSRect(x: 0, y: 0, width: 20, height: 18) + hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18) let controller = NSTitlebarAccessoryViewController() controller.view = hosting diff --git a/KanbanTests/BoardInfoPopoverTests.swift b/KanbanTests/BoardInfoPopoverTests.swift index 7ff8a9b..05aac4e 100644 --- a/KanbanTests/BoardInfoPopoverTests.swift +++ b/KanbanTests/BoardInfoPopoverTests.swift @@ -77,3 +77,72 @@ struct BoardGitSectionTests { #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) + } +} diff --git a/KanbanTests/InlineEditWriteTests.swift b/KanbanTests/InlineEditWriteTests.swift index 7e0cef6..15e20a8 100644 --- a/KanbanTests/InlineEditWriteTests.swift +++ b/KanbanTests/InlineEditWriteTests.swift @@ -972,14 +972,47 @@ struct BoardInfoAccessoryTests { #expect(window.titlebarAccessoryViewControllers.count == 1) 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 -/// **Card windows show no title in the title bar** — the card's name is shown as part of the card's -/// body instead (05-card-window.md ▸ Window: "card title is shown as part of card's body"), while the -/// toolbar renders exactly as it always has. `HostedWindowController.hideTitle()` is the seam -/// `CardWindowHost` reaches for; board windows call no such thing and keep AppKit's own default. Like +/// **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"), and a +/// board's is said by the board-popover widget in the titlebar instead (03-board-ui.md ▸ Board +/// 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 /// mechanism this app owns (`NSWindow.titleVisibility`) lands correctly, survives the macOS 26 /// 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") } - @Test("A window nobody calls hideTitle() on keeps AppKit's default — the board window's posture") - func boardWindowsKeepTheDefault() { + @Test("A window nobody calls hideTitle() on keeps AppKit's default — the restore-bootstrap window's posture") + func unconfiguredWindowsKeepTheDefault() { let window = makeWindow() let controller = HostedWindowController() controller.attach(to: window)