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
+34 -23
View File
@@ -203,36 +203,47 @@ struct ShowTrashCommand: View {
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched the
/// board it names is still right there.
///
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
/// Show Trash item is this same command with a different face (03-board-ui.md Toolbar: "toggle
/// state matching the View menu checkmark"), and the consequence above has to be true of both.
private var isVisible: Binding<Bool> {
Binding(
get: { store?.transient.isTrashVisible ?? false },
set: { shown in
guard let store else { return }
// The re-divide is a *user-initiated structural change* every lane compresses or
// relaxes as the trash's one unit joins or leaves the division so it animates in
// the structural voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a
// re-divide trigger", a lane add's behaviour exactly). It is also one of the few
// structural changes that never touches disk, which is why it wears its own
// transaction here instead of arriving through the reload seam like the rest.
//
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's
// content is built outside any rendered hierarchy, where the environment's
// accessibility values are not reliably populated (`Motion.prefersReducedMotion`).
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
store.transient.isTrashVisible = shown
// Dropped inside the same transaction: the rows it pointed at are leaving under
// this very animation, and a selection that cleared outside it would be the
// highlight easing on its own which 03 § Motion rules out ("the selection
// highlight rides whatever transaction is active").
if !shown, store.selection.liveness == .trashed {
store.clearSelection()
}
}
}
set: { shown in store?.setTrashVisible(shown) }
)
}
}
extension BoardStore {
/// Show/Hide Trash, with its one consequence **the whole of the View-menu row's behaviour**,
/// shared with the toolbar item that mirrors it.
func setTrashVisible(_ shown: Bool) {
// The re-divide is a *user-initiated structural change* every lane compresses or relaxes
// as the trash's one unit joins or leaves the division so it animates in the structural
// voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a
// lane add's behaviour exactly). It is also one of the few structural changes that never
// touches disk, which is why it wears its own transaction here instead of arriving through
// the reload seam like the rest.
//
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content
// is built outside any rendered hierarchy, where the environment's accessibility values are
// not reliably populated (`Motion.prefersReducedMotion`) and a toolbar item has no
// environment at all.
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
transient.isTrashVisible = shown
// Dropped inside the same transaction: the rows it pointed at are leaving under this
// very animation, and a selection that cleared outside it would be the highlight easing
// on its own which 03 § Motion rules out ("the selection highlight rides whatever
// transaction is active").
if !shown, selection.liveness == .trashed {
clearSelection()
}
}
}
}
// MARK: - The alert
extension View {