Implement live search filtering

The board's live title+body filter per 04-interactions.md § Search:

- SearchFilter — a pure value folding the query once (case- and
  diacritic-insensitive substring, locale-stable); title OR body matches,
  attachment filenames never searched; only the literal empty string is
  inactive.
- One universe: the filter threads through SelectionGrammar's order lists
  as a defaulted parameter, so ranges, Select All, arrow navigation, the
  marquee, drop zones, count badges, and the shown trash all read the same
  filtered set by construction; lanes are deliberately never filtered out
  (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the
  selection through the existing constrain primitive, run on every query
  change and as the last line of the reload resolve; the delete successor
  is filtered so ⌫ never selects a hidden neighbour.
- The field: an NSSearchField-backed toolbar item (the toolbar's sole
  default item); Edit ▸ Find ⌘F focuses it through a focused-value
  presentation; stock field-editor dispatch — Return swallowed, Tab is the
  keep-filter path to the board, board commands stay enabled except the
  caret-chord pair, now one shared caretChordsYield expression.
- Escape is staged: clear the non-empty query (focus stays), hand an empty
  field back to the board, clear an active search from board focus —
  before Escape's clear-selection meaning.
- Creating a card clears the search (the placeholder funnel); a rename
  deliberately gets no carve-out; filter reflow rides the content spring
  keyed narrowly on the query.

903 unit tests (24 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 22:49:15 -04:00
parent 7eee0934ee
commit cf87b72092
15 changed files with 1359 additions and 84 deletions
+40 -13
View File
@@ -79,6 +79,34 @@ extension BoardStore {
}
}
/// **The caret-chords rule, as one expression** (04-interactions.md Grammar, settled):
///
/// > Board Move Left/Move Right / and the width pair / disable via menu validation
/// > whenever *any* text control has keyboard focus inline title editors, the board search field,
/// > board-popover fields (rename, git identity, remote), and card-window fields because an
/// > enabled menu key equivalent fires before the field ever sees the key, and / are the
/// > standard line-start/end caret chords.
///
/// Four text surfaces, answered four ways, and only two of them are here:
///
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule a
/// broader lockdown that already covers these two items.
/// - **The board popover's fields** are covered by disabling while the popover is open at all
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
/// move belongs under it.
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
/// are already scopeless there.
///
/// Stated once because the two command groups must not drift: a rule with two implementations is a
/// rule with two chances to forget a surface.
@MainActor
func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool {
boardInfo?.isPresented == true || search?.isFocused == true
}
// MARK: - Open Card
/// Board Open Card () 11-command-nexus.md's first Board row, and **the one board command
@@ -205,32 +233,30 @@ struct MoveCardCommands: View {
///
/// **Caret chords yield to any focused text control** (04-interactions.md Grammar, settled):
/// / are the standard line-start/end chords, and an enabled key equivalent fires before a
/// field ever sees the key. The inline title editors are covered by `acceptsBoardMutations`; the
/// board popover's fields are covered by disabling while the popover is open at all coarser than
/// per-field focus, but the popover is a configuration surface (04's carve-out) and no lane move
/// belongs under it. The search field (m5-search) and the card window's fields (whose windows never
/// publish a `boardStore` in the first place) extend the same rule with their own cards.
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
/// `caretChordsYield(boardInfo:search:)`'s doc comment shared verbatim with the width pair below.
struct MoveLaneCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View {
Button("Move Left") {
move(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: .command)
.disabled(caretChordsYield || destination(-1) == nil)
.disabled(yieldsCaretChords || destination(-1) == nil)
Button("Move Right") {
move(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: .command)
.disabled(caretChordsYield || destination(1) == nil)
.disabled(yieldsCaretChords || destination(1) == nil)
}
private var caretChordsYield: Bool {
boardInfo?.isPresented == true
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The sole selected live lane and the display slot one step would put it in `nil` when there
@@ -431,23 +457,24 @@ struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View {
Button("Increase Lane Width") {
step(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
.disabled(caretChordsYield || selectedLanes.isEmpty)
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
Button("Decrease Lane Width") {
step(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
.disabled(caretChordsYield || !canDecrease)
.disabled(yieldsCaretChords || !canDecrease)
}
private var caretChordsYield: Bool {
boardInfo?.isPresented == true
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The selected live lanes, in snapshot order the batch, and the items' validation.