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:
2026-08-01 08:10:31 -04:00
parent 8206a78ecc
commit 6f2e0d15da
3 changed files with 124 additions and 0 deletions
+10
View File
@@ -851,6 +851,16 @@ struct CardWindowHost: View {
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments) 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 // **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 // 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 // represents ... and `window.undoManager` answers with it (standard per-window AppKit
+37
View File
@@ -91,6 +91,22 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
/// their board has loaded. /// their board has loaded.
private var toolbarController: WindowToolbarController? 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") private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
// MARK: Attachment // MARK: Attachment
@@ -110,6 +126,7 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
// window existed is installed here instead, and one handed over later installs immediately. // window existed is installed here instead, and one handed over later installs immediately.
addTitlebarAccessoryIfPossible() addTitlebarAccessoryIfPossible()
applyToolbarIfPossible() applyToolbarIfPossible()
applyTitleVisibilityIfPossible()
} }
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window /// 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 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 /// 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 /// standard path runs SwiftUI's own delegate gets its callbacks, tabbing behaves with the
/// flag telling our own `windowShouldClose` to stand aside. /// flag telling our own `windowShouldClose` to stand aside.
+77
View File
@@ -973,3 +973,80 @@ struct BoardInfoAccessoryTests {
controller.detach() 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")
}
}