From 6f2e0d15da4345bb7f6b76f8999bf580439c9c9c Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 1 Aug 2026 08:10:31 -0400 Subject: [PATCH] =?UTF-8?q?Card=20windows=20drop=20the=20title=20from=20th?= =?UTF-8?q?e=20title=20bar=20=E2=80=94=20the=20body=20already=20says=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/CardWindowHost.swift | 10 ++++ Kanban/App/WindowAccessor.swift | 37 +++++++++++++ KanbanTests/InlineEditWriteTests.swift | 77 ++++++++++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 5e54f5c..a196051 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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 diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index 59dbe7e..85e49f6 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -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. diff --git a/KanbanTests/InlineEditWriteTests.swift b/KanbanTests/InlineEditWriteTests.swift index 5567e07..7e0cef6 100644 --- a/KanbanTests/InlineEditWriteTests.swift +++ b/KanbanTests/InlineEditWriteTests.swift @@ -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") + } +}