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
+20 -10
View File
@@ -109,8 +109,14 @@ enum Motion {
/// restore reads like the search filter leavers and arrivers run their transition, survivors
/// reflow under one gentle spring".
///
/// Named ahead of both its call sites (m5's search, m7's undo) for the same reason `delete` is:
/// the figure is settled, and a duration that has nowhere to live gets spelled at a call site.
/// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction
/// keyed on the query and nothing else 03's own narrow key for this reflow. The leavers and
/// arrivers it talks about are `cardTransition`, already attached to every card slot and trash
/// row, so the two halves of the sentence are two modifiers rather than one bespoke animation.
///
// m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale
// reload, so it reaches this voice through `reloadAnimation` rather than through a transaction
// of its own see `reloadAnimates(origin:endsBracketedOperation:)`.
static func contentReflow(reduced: Bool) -> Animation? {
reduced ? nil : .smooth(duration: Duration.contentReflow)
}
@@ -145,8 +151,9 @@ enum Motion {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
}
/// A card arriving or leaving a create, a delete, a Put Back, and (m5) a search filter's
/// leavers and arrivers. The trash's rows wear it too: they are cards, and 10 requires the trash
/// A card arriving or leaving a create, a delete, a Put Back, and the search filter's leavers
/// and arrivers ("Search-hiding rides the same structural transition hiding is removal, not a
/// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
/// animations to have a reduced variant like everything else.
static func cardTransition(reduced: Bool) -> AnyTransition {
cardAppearance(reduced: reduced).transition
@@ -243,12 +250,15 @@ enum Motion {
/// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
/// applied in, or `nil` for the reloads that snap.
///
// m5-drag, m5-search: the voice is the *general* structural spring for every app-mediated
// reload, because the reload seam knows an operation echoed but not which one 03 gives delete
// 0.25 s and a drop commit its own dialect, and neither is reachable from an origin tag. The
// per-operation figures (`delete`, `dragReflow`) become reachable when the operations that own
// them run their own transactions around the gesture, which is m5's card; this seam stays the
// floor under them.
// m5-drag: the voice is the *general* structural spring for every app-mediated reload, because
// the reload seam knows an operation echoed but not which one 03 gives delete 0.25 s and a
// drop commit its own dialect, and neither is reachable from an origin tag. The per-operation
// figures (`delete`, `dragReflow`) become reachable when the operations that own them run their
// own transactions around the gesture; this seam stays the floor under them.
//
// The search filter needed none of that and never reaches here: a query change is not a reload
// at all it is transient state, so its transaction is wrapped where it happens
// (`BoardView.laneStrip`, `contentReflow`).
static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
return structural(reduced: reduced)