Card windows drop the title from the title bar — the body already says it
The card's name renders as part of the card body, so the chrome said it twice. HostedWindowController gains a titleVisibility slot on the same retained-slot pattern as the accessory and toolbar (reapplied at attach, so the macOS 26 provisional-window swap can't lose it); CardWindowHost opts in beside installToolbar, board windows keep AppKit's default. window.title itself is untouched — navigationTitle still feeds the Window menu, Exposé, VoiceOver and restoration. Known consequence, probed empirically: under .hidden AppKit collapses title and subtitle into one hidden field, so the "board › lane" navigationSubtitle disappears with it — and the details sidebar carries no placement rows, so a card window currently shows its home nowhere. Filed on the Redesign board to bless or relocate; 05-card-window.md's subtitle sentence needs reconciling either way. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -851,6 +851,16 @@ struct CardWindowHost: View {
|
||||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||||
)
|
||||
|
||||
// **No title in the title bar** — the card's name is shown as part of the card's body
|
||||
// instead (the large-title text at the top of the body column, `bodyColumn`), so the chrome
|
||||
// does not say it twice. `window.title` itself is untouched — `.navigationTitle(windowTitle)`
|
||||
// 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.
|
||||
windowController.hideTitle()
|
||||
|
||||
// **This window's own stack** (13-native-undo.md ▸ Rules ▸ two levels, re-ruled 2026-07-31 —
|
||||
// superseding the shared-stack wiring): "a card window owns its own stack for the session it
|
||||
// represents ... and `window.undoManager` answers with it (standard per-window AppKit
|
||||
|
||||
@@ -91,6 +91,22 @@ 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.
|
||||
///
|
||||
/// 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
|
||||
/// real window attaches, which a write made once at `onAttach` time would not survive if that
|
||||
/// closure only fired for the provisional window. `NSWindow.title` itself is a different slot
|
||||
/// entirely — SwiftUI's `navigationTitle` sets it directly, and it is left alone on purpose: the
|
||||
/// Window menu, Mission Control/Exposé, VoiceOver and state restoration all read the string, not
|
||||
/// what the chrome draws from it.
|
||||
private var titleVisibility: NSWindow.TitleVisibility?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||
|
||||
// MARK: Attachment
|
||||
@@ -110,6 +126,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
// window existed is installed here instead, and one handed over later installs immediately.
|
||||
addTitlebarAccessoryIfPossible()
|
||||
applyToolbarIfPossible()
|
||||
applyTitleVisibilityIfPossible()
|
||||
}
|
||||
|
||||
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
|
||||
@@ -192,6 +209,26 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
window.toolbar = nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
///
|
||||
/// 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,
|
||||
/// unlike the accessory and toolbar slots: writing `NSWindow.titleVisibility` twice has no side
|
||||
/// effect worth guarding against, so this is not an install-once seam.
|
||||
func hideTitle() {
|
||||
titleVisibility = .hidden
|
||||
applyTitleVisibilityIfPossible()
|
||||
}
|
||||
|
||||
private func applyTitleVisibilityIfPossible() {
|
||||
guard let window, let titleVisibility else { return }
|
||||
window.titleVisibility = titleVisibility
|
||||
}
|
||||
|
||||
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||
/// flag telling our own `windowShouldClose` to stand aside.
|
||||
|
||||
@@ -973,3 +973,80 @@ struct BoardInfoAccessoryTests {
|
||||
controller.detach()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
/// `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
|
||||
/// controller reapplies to whichever window actually sticks around), and leaves `window.title` itself
|
||||
/// alone, since that string is what the Window menu, Mission Control/Exposé, VoiceOver and state
|
||||
/// restoration all read.
|
||||
@MainActor
|
||||
@Suite("Card window chrome ▸ hidden title")
|
||||
struct CardWindowTitleVisibilityTests {
|
||||
|
||||
private func makeWindow(title: String = "Fix login") -> NSWindow {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = title
|
||||
return window
|
||||
}
|
||||
|
||||
@Test("hideTitle() hides the title bar's title but leaves window.title alone")
|
||||
func hidesTitleLeavesTitleStringAlone() {
|
||||
let window = makeWindow()
|
||||
let controller = HostedWindowController()
|
||||
controller.attach(to: window)
|
||||
|
||||
controller.hideTitle()
|
||||
|
||||
#expect(window.titleVisibility == .hidden)
|
||||
#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() {
|
||||
let window = makeWindow()
|
||||
let controller = HostedWindowController()
|
||||
controller.attach(to: window)
|
||||
|
||||
#expect(window.titleVisibility == .visible)
|
||||
}
|
||||
|
||||
@Test("Hiding before the window exists still lands once one attaches")
|
||||
func hidesBeforeAttach() {
|
||||
let controller = HostedWindowController()
|
||||
controller.hideTitle()
|
||||
|
||||
let window = makeWindow()
|
||||
controller.attach(to: window)
|
||||
|
||||
#expect(window.titleVisibility == .hidden)
|
||||
}
|
||||
|
||||
@Test("The setting survives the provisional-window swap: detach, then attach to the real window")
|
||||
func survivesDetachAttachCycle() {
|
||||
let provisional = makeWindow()
|
||||
let controller = HostedWindowController()
|
||||
controller.attach(to: provisional)
|
||||
controller.hideTitle()
|
||||
#expect(provisional.titleVisibility == .hidden)
|
||||
|
||||
controller.detach()
|
||||
|
||||
let real = makeWindow(title: "Fix login")
|
||||
controller.attach(to: real)
|
||||
|
||||
#expect(real.titleVisibility == .hidden, "reapplied on the window that actually sticks around")
|
||||
#expect(real.title == "Fix login", "window.title still flows independently of the chrome swap")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user