From 94e60cd44439aeadf79b2f0ed83a1bbd506dccd3 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 1 Aug 2026 08:28:53 -0400 Subject: [PATCH] =?UTF-8?q?The=20whole=20board-name=20area=20opens=20the?= =?UTF-8?q?=20popover=20=E2=80=94=20the=20chevron=20alone=20was=20the=20tr?= =?UTF-8?q?igger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/BoardWindowHost.swift | 7 ++ Kanban/App/CardWindowHost.swift | 6 +- Kanban/App/WindowAccessor.swift | 20 ++-- Kanban/UI/Board/BoardInfoPopover.swift | 140 +++++++++++++++++++++--- KanbanTests/BoardInfoPopoverTests.swift | 69 ++++++++++++ KanbanTests/InlineEditWriteTests.swift | 45 +++++++- 6 files changed, 253 insertions(+), 34 deletions(-) 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)