Implement toolbar customization for both windows

NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 12:57:42 -04:00
parent 40322247e0
commit 06ee59e24b
13 changed files with 1683 additions and 131 deletions
+17
View File
@@ -132,12 +132,15 @@ enum Motion {
enum Appearance: Equatable {
/// Scales up from `from` while fading in (and the reverse on the way out).
case scaleAndFade(from: CGFloat)
/// Slides in from `edge` while fading in, and leaves the way it came.
case slideAndFade(from: Edge)
/// Opacity only 10-accessibility.md's "crossfade" variant.
case crossfade
var transition: AnyTransition {
switch self {
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
case .crossfade: .opacity
}
}
@@ -165,6 +168,20 @@ enum Motion {
laneAppearance(reduced: reduced).transition
}
/// The transient search bar arriving and leaving F's fallback when the search field has been
/// removed from the toolbar (03-board-ui.md Toolbar: "with the field removed from the toolbar,
/// invoking it surfaces the field transiently until the search clears").
///
/// It comes from the top edge because that is where the field lives when it is installed: the
/// bar is the toolbar's item arriving one row lower, not a new kind of surface.
static func transientSearchAppearance(reduced: Bool) -> Appearance {
reduced ? .crossfade : .slideAndFade(from: .top)
}
static func transientSearchTransition(reduced: Bool) -> AnyTransition {
transientSearchAppearance(reduced: reduced).transition
}
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number