diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index d2f10a8..88b3b0d 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -81,6 +81,17 @@ struct BoardWindowHost: View { /// get around to it. @State private var openWalk = BoardOpenWalk() + /// The loading window's plain-text stand-in for the titlebar widget + /// (`boardLoadingTitlebarAccessory`), held only so `configureWindow` can find and remove it once + /// the real, interactive widget is ready to take its place. + /// + /// Not routed through `windowController`'s own accessory slot (`installTitlebarAccessory`), + /// which is install-once and holds exactly one accessory for this window's whole life — this one + /// is deliberately temporary, so it is added and removed with the raw `NSWindow` API instead, + /// from the same `onAttach` closure that already survives the provisional-window swap + /// (`configureLoadingWindow`). + @State private var loadingAccessory: NSTitlebarAccessoryViewController? + /// This board's registry record, from the moment `recordOpen` mints it — which is what the /// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation /// that precedes `start()`. @@ -680,14 +691,27 @@ struct BoardWindowHost: View { /// is replaced wholesale by the flushing version once the board is open (see below); a single /// closure branching on `phase` would be the same thing spelled as a state read. /// - /// The title bar keeps AppKit's own title display for now — the string is the record's cached - /// name (`windowTitle`) — and `hideTitle()` follows only once the board-popover widget is there - /// to say the name instead. Hiding it here would leave a loading window with no name anywhere in - /// its chrome, which is precisely what 02 asks the loading state to carry. + /// The title bar carries the record's cached name from the first frame (`windowTitle`) — not + /// through AppKit's own title display, which the board scene's `.windowToolbarStyle(.unified + /// (showsTitle: false))` (`KanbanApp`) keeps hidden on every board window unconditionally, but + /// through a plain-text stand-in widget (`boardLoadingTitlebarAccessory`) installed here. + /// `configureWindow(store:recordID:)` swaps it for the real, interactive one once the + /// board-popover widget is there to say the name instead — leaving the loading window with no + /// name anywhere in its chrome for even one frame is precisely what 02 asks the loading state + /// not to do. private func configureLoadingWindow(recordID: UUID) { windowController.onAttach = { window in - guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return } - window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) + if let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame { + window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) + } + // Fresh per attach, deliberately: this closure re-fires on the provisional-window swap + // (the comment below), and a stand-in built for a window that is about to be discarded + // would be a stale reference this state never sees again. + let accessory = boardLoadingTitlebarAccessory( + title: Self.loadingTitle(record: appModel.boardRegistry.record(id: recordID), url: ref.url) + ) + loadingAccessory = accessory + window.addTitlebarAccessoryViewController(accessory) } // The window may already be attached — `viewDidMoveToWindow` fires before this task's first // suspension — so the placement is applied directly too rather than waiting for a callback @@ -775,6 +799,15 @@ struct BoardWindowHost: View { // asks a delegate for a manager. windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager } + // The loading window's plain-text stand-in (`configureLoadingWindow`) has done its job — + // found by identity, `HostedWindowController.removeTitlebarAccessory`'s own pattern, because + // nothing else promises this is the only accessory the window carries by the time it exists. + if let loadingAccessory, let window = windowController.window, + let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === loadingAccessory }) { + window.removeTitlebarAccessoryViewController(at: index) + } + self.loadingAccessory = nil + // The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which // is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and // card windows share that machinery and have no board to describe. It goes in after the @@ -794,10 +827,15 @@ struct BoardWindowHost: View { // keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the title bar's own // rendering of that string is suppressed. // - // **After the load, and only after it**, which is why it is not in the loading half above: - // this line and the widget it defers to are one exchange, and a loading window that hid its - // title before the widget existed would carry no name at all — against 02's "its chrome - // carrying the registry record's cached title". + // **Not this call's job anymore, keeping it hidden** — the duplicate-name bug this call once + // let through, occasionally, once the board had been open long enough to re-render: a + // SwiftUI scene window's `titleVisibility` is SwiftUI's to hold, and it writes the tree's + // resolved value back on every pass that re-applies a window's configuration, so this + // out-of-band write alone would survive only until the next board-driven re-render. + // `KanbanApp`'s board scene now declares + // `.windowToolbarStyle(.unified(showsTitle: false))` unconditionally, which is what SwiftUI + // keeps re-asserting; this call stays as the same value one turn earlier — correct from the + // instant it runs, not the one thing making it last. windowController.hideTitle() // The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 226c2fc..71521c8 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -143,6 +143,32 @@ struct KanbanApp: App { .restorationBehavior(.disabled) .defaultLaunchBehavior(.suppressed) .commands { menuCommands } + // **The system title is not this scene's to show, ever** — a board window says its own + // name through the titlebar widget (`BoardInfoWidget`/`boardLoadingTitlebarAccessory`, + // `BoardWindowHost`), never through AppKit's own title rendering, so the two can no longer + // draw beside each other (the duplicate-name bug this scene modifier fixes). + // + // Declared here, at the *scene*, rather than only as `HostedWindowController.hideTitle()`'s + // out-of-band `NSWindow.titleVisibility` write, for a429a7e's own reason restated for a + // second AppKit knob: `titleVisibility` on a SwiftUI scene window is SwiftUI's to hold, and + // it writes the tree's resolved value back every time it re-applies a window's + // configuration — a tree that says nothing resolves `.visible`, so a later pass (any body + // re-evaluation this window's board causes — a snapshot reload, a banner, a search-field + // focus change) put the system title back beside the widget some time after the window + // opened correct. Verified as an A/B harness (not the live app; documented honestly rather + // than reproduced on screen — see the card journal): an out-of-band `.hidden` write reverts + // to `.visible` on the very next `@State`-driven render with nothing declared here, and + // holds through the same pressure (resize, key-status changes, repeated renders) once this + // line is added. + // + // **Unconditional**, unlike `toolbarBackgroundVisibility` in `BoardWindowHost`: this is a + // *scene* modifier, so it cannot read one window's live phase the way a `View` modifier + // bound to `store.snapshot` can — every board window it creates gets the same posture, + // always. That is also the right posture: this app never wants the system to draw a board + // window's title, not even for the moment before the widget exists, which is why + // `configureLoadingWindow` now gives the loading window a plain-text stand-in widget instead + // of leaning on the system title for that moment (`BoardWindowHost`). + .windowToolbarStyle(.unified(showsTitle: false)) WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in if let ref { diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index bbc4b46..4e164a7 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -249,6 +249,47 @@ func boardInfoTitlebarAccessory( return controller } +/// The widget's stand-in for the board window's brief life before it has a store — the loading +/// window's own name (`BoardWindowHost.loadingTitle`), styled to match the real widget's title +/// line, with no icon (the registry record carries no cached glyph, `BoardRecord`), no chevron and +/// no popover: there is nothing to open yet. +/// +/// **Why the loading window needs any widget at all**, now that `KanbanApp`'s board scene declares +/// `.windowToolbarStyle(.unified(showsTitle: false))` unconditionally: that modifier is the scene's, +/// not this window's, so it cannot wait for the store the way `HostedWindowController.hideTitle()` +/// used to — every board window it creates keeps the system title hidden from its very first frame, +/// loading or not. Leaving the loading window with no name anywhere in its chrome would be exactly +/// what 02-architecture.md's "its chrome carrying the registry record's cached title" rules out, so +/// `BoardWindowHost.configureLoadingWindow` installs this in the system title's place and +/// `configureWindow` swaps it for `boardInfoTitlebarAccessory` the moment the real one exists. +@MainActor +func boardLoadingTitlebarAccessory(title: String) -> NSTitlebarAccessoryViewController { + let hosting = NSHostingView( + rootView: HStack(spacing: 6) { + // A glyph-shaped gap rather than the glyph itself — there is no cached icon to draw + // (`BoardRecord` carries none), but leaving the text flush left would have it jump right + // by the icon's width at the swap. 22pt is `BoardInfoWidget`'s own icon size. + Color.clear.frame(width: 22, height: 22) + Text(title) + // The same line the real widget draws (`BoardInfoWidget.body`), so the swap to the + // interactive widget reads as a content change close to in place, not a jump. + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: 400, alignment: .leading) + .frame(height: 32) + ) + 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 diff --git a/KanbanTests/BoardLoadingTests.swift b/KanbanTests/BoardLoadingTests.swift index 962e3d9..0261e4a 100644 --- a/KanbanTests/BoardLoadingTests.swift +++ b/KanbanTests/BoardLoadingTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @testable import Kanban @@ -175,3 +176,70 @@ struct BoardLoadingTests { ) } } + +// MARK: - The loading window's titlebar stand-in + +/// **The duplicate-name bug's fix** (Pipeline card a73bad86): a board window's system title is kept +/// permanently hidden by `KanbanApp`'s `.windowToolbarStyle(.unified(showsTitle: false))` — a scene +/// modifier that cannot wait for the store, so it hides the system title on the loading window too, +/// before there is a `BoardStore` to build the real widget from. `boardLoadingTitlebarAccessory` +/// is what fills that gap, and `BoardWindowHost.configureWindow` swaps it for the real widget by +/// removing it from the window by identity — the same pattern +/// `HostedWindowController.removeTitlebarAccessory` uses for its own slot, pinned again here because +/// the swap itself runs inline in a private method with no seam of its own to call directly. +@MainActor +@Suite("Board loading state ▸ the titlebar stand-in") +struct BoardLoadingTitlebarStandInTests { + + private static func window() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: true + ) + } + + @Test("It lays out leading, exactly like the real widget it stands in for") + func laysOutLeading() { + let accessory = boardLoadingTitlebarAccessory(title: "Roadmap") + #expect(accessory.layoutAttribute == .leading) + // Wide and tall enough that it is never a zero-size, invisible widget — the intrinsic + // measurement `.intrinsicContentSize` runs settles the exact figure, which is not the part + // worth pinning; not being clipped to nothing is. + #expect(accessory.view.frame.width > 0) + #expect(accessory.view.frame.height > 0) + } + + @Test("Removing it by identity leaves any other accessory the window carries untouched") + func removalByIdentityIsTargeted() { + let window = Self.window() + let standIn = boardLoadingTitlebarAccessory(title: "Roadmap") + let other = boardLoadingTitlebarAccessory(title: "Some Other Board") + window.addTitlebarAccessoryViewController(standIn) + window.addTitlebarAccessoryViewController(other) + #expect(window.titlebarAccessoryViewControllers.count == 2) + + // `BoardWindowHost.configureWindow`'s own removal: find by identity, remove by index. + if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === standIn }) { + window.removeTitlebarAccessoryViewController(at: index) + } + + #expect(window.titlebarAccessoryViewControllers.count == 1) + #expect(window.titlebarAccessoryViewControllers.first === other, "the untargeted one survives") + } + + @Test("A window with no stand-in installed is left alone by the same removal") + func removalIsANoOpWithoutOne() { + let window = Self.window() + let other = boardLoadingTitlebarAccessory(title: "Some Other Board") + window.addTitlebarAccessoryViewController(other) + + let standIn: NSTitlebarAccessoryViewController? = nil + if let standIn, let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === standIn }) { + window.removeTitlebarAccessoryViewController(at: index) + } + + #expect(window.titlebarAccessoryViewControllers.count == 1, "nothing to remove, nothing removed") + } +}