The board window says its name once — the scene declares the title SwiftUI keeps hiding
`HostedWindowController.hideTitle()` was the same shape of buga429a7efixed 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 ina429a7e. `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
This commit is contained in:
@@ -81,6 +81,17 @@ struct BoardWindowHost: View {
|
|||||||
/// get around to it.
|
/// get around to it.
|
||||||
@State private var openWalk = BoardOpenWalk()
|
@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
|
/// 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
|
/// loading window's title reads (`Self.loadingTitle`). `nil` only for the one body evaluation
|
||||||
/// that precedes `start()`.
|
/// 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
|
/// 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.
|
/// 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
|
/// The title bar carries the record's cached name from the first frame (`windowTitle`) — not
|
||||||
/// name (`windowTitle`) — and `hideTitle()` follows only once the board-popover widget is there
|
/// through AppKit's own title display, which the board scene's `.windowToolbarStyle(.unified
|
||||||
/// to say the name instead. Hiding it here would leave a loading window with no name anywhere in
|
/// (showsTitle: false))` (`KanbanApp`) keeps hidden on every board window unconditionally, but
|
||||||
/// its chrome, which is precisely what 02 asks the loading state to carry.
|
/// 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) {
|
private func configureLoadingWindow(recordID: UUID) {
|
||||||
windowController.onAttach = { window in
|
windowController.onAttach = { window in
|
||||||
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
|
if let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame {
|
||||||
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
|
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
|
// 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
|
// 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.
|
// asks a delegate for a manager.
|
||||||
windowController.windowUndoManager = { appModel.session(for: ref)?.undoManager }
|
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
|
// 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
|
// 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
|
// 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
|
// keeps feeding the Window menu, Exposé, VoiceOver and restoration; only the title bar's own
|
||||||
// rendering of that string is suppressed.
|
// rendering of that string is suppressed.
|
||||||
//
|
//
|
||||||
// **After the load, and only after it**, which is why it is not in the loading half above:
|
// **Not this call's job anymore, keeping it hidden** — the duplicate-name bug this call once
|
||||||
// this line and the widget it defers to are one exchange, and a loading window that hid its
|
// let through, occasionally, once the board had been open long enough to re-render: a
|
||||||
// title before the widget existed would carry no name at all — against 02's "its chrome
|
// SwiftUI scene window's `titleVisibility` is SwiftUI's to hold, and it writes the tree's
|
||||||
// carrying the registry record's cached title".
|
// 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()
|
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
|
||||||
|
|||||||
@@ -143,6 +143,32 @@ struct KanbanApp: App {
|
|||||||
.restorationBehavior(.disabled)
|
.restorationBehavior(.disabled)
|
||||||
.defaultLaunchBehavior(.suppressed)
|
.defaultLaunchBehavior(.suppressed)
|
||||||
.commands { menuCommands }
|
.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
|
WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in
|
||||||
if let ref {
|
if let ref {
|
||||||
|
|||||||
@@ -249,6 +249,47 @@ func boardInfoTitlebarAccessory(
|
|||||||
return controller
|
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
|
// MARK: - Tabs
|
||||||
|
|
||||||
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AppKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Kanban
|
@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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user