diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 1e36ebc..d2f10a8 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -171,6 +171,11 @@ struct BoardWindowHost: View { // Nothing to render and nothing worth animating: this window is dismissing itself. Color.clear case let .open(store): + // **Whether this board paints a background of its own**, read once and spent twice + // below — the AppKit half of the chrome decision (`setExtendsContentUnderTitlebar`) and + // the SwiftUI half (`toolbarBackgroundVisibility`) are one answer, not two, and reading + // it twice would be the first step towards their disagreeing. + let custom = BoardBackdrop.isCustom(store.snapshot, root: store.rootURL) VStack(spacing: 0) { BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) } // **⌘F's fallback**, and only that: the search field's home is the toolbar item @@ -221,9 +226,31 @@ struct BoardWindowHost: View { // first render is already a level, not a change — this is the board's first statement // about its chrome, and the loading half deliberately made none // (`HostedWindowController.extendsUnderTitlebar`). - .onChange(of: BoardBackdrop.isCustom(store.snapshot, root: store.rootURL), initial: true) { _, custom in + .onChange(of: custom, initial: true) { _, custom in windowController.setExtendsContentUnderTitlebar(custom) } + // **And the same sentence said to SwiftUI, which is the half that lasts.** + // + // `NSWindow.titlebarAppearsTransparent` is not the app's property to hold on a SwiftUI + // scene window: it is the AppKit face of *this* modifier, and SwiftUI writes it — the + // resolved value, whether or not it changed — every time it re-applies a window's + // configuration. A tree that says nothing resolves `.automatic`, so every such pass + // wrote `false` over the `true` the line above had just set out of band. The board + // opened correct, because the load lands after the pass that follows window creation, + // and turned opaque at the next one — a title bar painting its own material and its + // `titlebarSeparatorStyle` hairline across a picture that is still being drawn full + // height underneath, which is exactly what the bug's screenshots show (nothing below + // the bar moves, because `.fullSizeContentView` was never the half that was lost). + // + // Stating it here makes SwiftUI's own passes assert the posture the board asked for + // instead of undoing it. Verified as an A/B against a real configuration pass: the same + // AppKit write survives with this modifier declared and is reverted within ~15 ms + // without it. + // + // The AppKit write stays, and is not a second owner of the same truth — it is the same + // value one turn earlier, so the frame in which a `background:` first reads is already + // drawn under a clear bar. What it cannot do is *stay* said; that is this line's job. + .toolbarBackgroundVisibility(Self.toolbarBackground(overCustomBackground: custom), for: .windowToolbar) // (**The board settings sheet was presented here** between 2026-07-31 and 2026-08-07, // when the popover/sheet split was reversed: the sheet retired, its contents rehomed // into the popover's Git tab — itself retired 2026-08-08 with the git excision @@ -288,6 +315,23 @@ struct BoardWindowHost: View { record?.displayName ?? AppModel.folderDisplayName(of: url) } + /// **What the window toolbar's background does over this board** — gone when the board paints + /// one of its own, the system's own answer when it does not (03-board-ui.md § Styling ▸ + /// Capabilities: the board's colour or image "paints the full window"). + /// + /// `.automatic` rather than `.visible` for the ordinary board, deliberately: a board with no + /// background of its own must keep "the standard chrome exactly as it has always looked", and + /// only `.automatic` means *whatever the system would have done* — including the scroll-driven + /// appearance AppKit manages for itself. `.visible` would pin a bar on permanently and would be + /// this modifier's own version of the bug it exists to fix. + /// + /// Static and pure for `loadingTitle`'s reason: this one line is the whole of the rule that a + /// board's chrome follows its background, and an inversion here is invisible in review and + /// obvious on screen. + static func toolbarBackground(overCustomBackground custom: Bool) -> Visibility { + custom ? .hidden : .automatic + } + // MARK: - Opening /// Starts the open as a task of its own, so something can hold it. diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index 67fc2f2..07736c4 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -285,6 +285,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate { /// what stops the title bar from painting its own material over it. Either alone is a visible /// half-state — an opaque bar over the board, or a board that stops at a bar that no longer /// draws. + /// + /// **The second knob does not stay written, and this is not the place that keeps it.** On a + /// SwiftUI scene window `titlebarAppearsTransparent` is SwiftUI's: it is the AppKit face of the + /// view tree's resolved `toolbarBackgroundVisibility(for: .windowToolbar)`, and SwiftUI writes + /// the resolved value — changed or not — on every pass in which it re-applies a window's + /// configuration. A tree that states nothing resolves `.automatic`, so each of those passes put + /// `false` back over what this method had set, and the board's title bar went opaque some time + /// after the board opened rather than at once. So the board *also* says it in SwiftUI + /// (`BoardWindowHost`, which owns the reasoning), and this write is what makes the posture true + /// for the turn in which the board's `background:` first reads, not what makes it last. + /// + /// The `fullSizeContentView` half has no such second owner and survives on its own. private func applyTitlebarExtensionIfPossible() { guard let window, let extendsUnderTitlebar else { return } window.titlebarAppearsTransparent = extendsUnderTitlebar diff --git a/KanbanTests/BoardBackgroundTests.swift b/KanbanTests/BoardBackgroundTests.swift index 933da0e..035830b 100644 --- a/KanbanTests/BoardBackgroundTests.swift +++ b/KanbanTests/BoardBackgroundTests.swift @@ -1,4 +1,6 @@ +import AppKit import Foundation +import SwiftUI import Testing @testable import Kanban @@ -441,3 +443,90 @@ struct BackgroundImagePathTests { #expect(BoardBackdrop.isCustom(try board("background: {color: mauve, image: /tmp/x.jpg}"), root: root) == false) } } + +// MARK: - The window chrome that follows the background + +/// **A board that paints a background runs its content under a clear title bar** (03-board-ui.md § +/// Styling ▸ Capabilities), and that posture is said in two places on purpose. +/// +/// `NSWindow.titlebarAppearsTransparent` belongs to SwiftUI on a scene window — it is the AppKit +/// face of `toolbarBackgroundVisibility(for: .windowToolbar)`, and SwiftUI writes the value its tree +/// resolves on every pass in which it re-applies a window's configuration. A tree that said nothing +/// resolved `.automatic`, so those passes wrote `false` back over the flag `HostedWindowController` +/// had set out of band: the board opened correct and its title bar turned opaque some time later, +/// painting its own material and its separator hairline across a picture still being drawn full +/// height underneath. So `BoardWindowHost` states it in SwiftUI as well, and the two tests here pin +/// the two halves — the rule SwiftUI is told, and the window the controller writes. +@MainActor +@Suite("Board background ▸ the window chrome follows it") +struct BoardChromeTests { + + @Test("The toolbar background is gone over a board that paints one, and the system's otherwise") + func toolbarBackgroundFollowsTheBoard() { + #expect(BoardWindowHost.toolbarBackground(overCustomBackground: true) == .hidden) + // `.automatic`, never `.visible` — a board with no background of its own keeps whatever the + // system would have drawn, which is what "the standard chrome, untouched" means. + #expect(BoardWindowHost.toolbarBackground(overCustomBackground: false) == .automatic) + } + + @Test("The controller says nothing about a window's title bar until it is told") + func silentUntilTold() { + let window = Self.window() + let controller = HostedWindowController() + controller.attach(to: window) + + // `nil` and `false` render identically; they differ only in whether this controller has + // spoken, which is what keeps the loading half from stating a default it does not own. + #expect(window.titlebarAppearsTransparent == false) + #expect(window.styleMask.contains(.fullSizeContentView) == false) + } + + @Test("Both knobs move together, in both directions, however often they are set") + func bothKnobsMoveTogether() { + let window = Self.window() + let controller = HostedWindowController() + controller.attach(to: window) + + controller.setExtendsContentUnderTitlebar(true) + #expect(window.titlebarAppearsTransparent) + #expect(window.styleMask.contains(.fullSizeContentView)) + + // Repeat-safe: the board drives this off its snapshot, so it is called on every reload that + // changes the reading and on plenty that do not. + controller.setExtendsContentUnderTitlebar(true) + #expect(window.titlebarAppearsTransparent) + #expect(window.styleMask.contains(.fullSizeContentView)) + + // And back — a `background:` deleted on disk puts the standard chrome back. + controller.setExtendsContentUnderTitlebar(false) + #expect(window.titlebarAppearsTransparent == false) + #expect(window.styleMask.contains(.fullSizeContentView) == false) + } + + @Test("The posture survives the provisional-window swap") + func theSlotReappliesToTheNextWindow() { + // SwiftUI dismantles and re-makes the background representable while it moves a scene's + // content into its final window, so a posture stated against the first window has to reach + // the second (`HostedWindowController.detach`). + let provisional = Self.window() + let controller = HostedWindowController() + controller.attach(to: provisional) + controller.setExtendsContentUnderTitlebar(true) + + controller.detach() + let real = Self.window() + controller.attach(to: real) + + #expect(real.titlebarAppearsTransparent) + #expect(real.styleMask.contains(.fullSizeContentView)) + } + + 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 + ) + } +}