From f4b2de55fbfc3db5c3cdb7145eac623d986d9102 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 11:55:58 -0400 Subject: [PATCH] =?UTF-8?q?The=20board=20window=20says=20its=20name=20once?= =?UTF-8?q?=20=E2=80=94=20the=20scene=20declares=20the=20title=20SwiftUI?= =?UTF-8?q?=20keeps=20hiding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HostedWindowController.hideTitle()` was the same shape of bug a429a7e fixed for `titlebarAppearsTransparent`: an out-of-band `NSWindow.titleVisibility` write, correct the instant it ran, undone by SwiftUI's own next pass over the window's configuration — a tree that declares nothing resolves `.visible`, and SwiftUI writes that back over the out-of-band `.hidden` on the very next `@State`-driven re-render this board window's own liveness causes. The system title reappeared beside the board-popover widget, "occasionally" — whenever that next re-render happened to land. Confirmed with an A/B harness (no interactive display in this session, so not reproduced on screen; mechanism established in code, per the card's own fallback): a bare out-of-band write held indefinitely against resize and key-status changes alone, but reverted on the very next `@State`-driven render and stayed reverted — reasserting from `body`'s own construction or from `.onChange` both lost the same race, since SwiftUI's resync runs later than either. The only thing that held was declaring the posture in the tree itself, mirroring `.toolbarBackgroundVisibility`'s role in a429a7e. `KanbanApp`'s board `WindowGroup` now declares `.windowToolbarStyle(.unified(showsTitle: false))`. It is a scene modifier, not a per-window one, so — unlike `.toolbarBackgroundVisibility` — it cannot wait for a board's load to finish before taking effect; every board window it creates keeps the system title hidden from its very first frame. `boardLoadingTitlebarAccessory` covers the gap that opens before the loading window has a store to build the real widget from: a small, non-interactive, plain-text stand-in carrying the registry record's cached name, installed the moment the window attaches and swapped by identity for the real widget the moment the store loads — so the loading window's chrome still carries a name throughout, per 02-architecture.md. `hideTitle()`'s own write stays; it is no longer what keeps the title hidden, but it is still correct for the one render turn before the scene's own re-assertion catches up. Confined to `BoardWindowHost.swift`, `BoardInfoPopover.swift` and `KanbanApp.swift` — `WindowAccessor.swift`'s shared `hideTitle()`/`titleVisibility` machinery is untouched, since a concurrent fix is addressing the card window's version of this same bug through that file. New regression tests (`BoardLoadingTitlebarStandInTests`, `KanbanTests/BoardLoadingTests.swift`) pin the stand-in's layout and the identity-based swap. Full suite green (3219 tests) except the pre-existing, documented environment-sensitive `PointerLatencyTests`, confirmed unaffected by rerunning them in isolation. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- Kanban/App/BoardWindowHost.swift | 58 ++++++++++++++++++---- Kanban/KanbanApp.swift | 26 ++++++++++ Kanban/UI/Board/BoardInfoPopover.swift | 41 ++++++++++++++++ KanbanTests/BoardLoadingTests.swift | 68 ++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 10 deletions(-) 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") + } +}