Build the sole-selection attachment carousel

The card face's in-place expansion (03-board-ui.md § Card face): a card
with attachments, as the sole selection, grows a paged media band below
its title; every other state stays compact behind the quiet paperclip.

- CardCarousel owns the pure rules: the sole-selection predicate decides
  by identity (a sole-selected lane matches no face, no snapshot walk),
  and expansion is suppressed outside the animation key while a rubber
  band is active — a band names a set in progress, so carousels neither
  flicker nor animate under it.
- QuickLook thumbnails generate off-main into a per-window cache keyed to
  survive reselection, with the Finder-icon fallback while loading and
  for non-previewable types; pages ride the platform paging behavior,
  dots (glass underlay, solid under Reduce Transparency) click to page,
  and a local wheel monitor turns a discrete tick into one clamped page —
  precise trackpad pans fall through untouched.
- The expansion animates under Motion's new carouselExpansion transaction
  keyed narrowly on the sole-selected card; Reduce Motion goes instant.
  Drop-slot math and the marquee read the expanded height for free — both
  re-register on every size change.

928 unit tests (24 new). m5-interactions complete.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 00:21:13 -04:00
parent 6c490ec71c
commit 2fc4020a2b
9 changed files with 1111 additions and 17 deletions
+9
View File
@@ -94,6 +94,14 @@ struct BoardView: View {
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
@State private var marqueeTargets = MarqueeTargetRegistry()
/// This window's attachment thumbnails, for the sole-selected card's carousel (03-board-ui.md
/// § Card face). `@State` for the sessions' reason one per window, living exactly as long as
/// the window, so a board that closes forgets the files it was showing (see
/// `AttachmentThumbnailCache` on why the window and not the app). Handed down through the
/// environment rather than threaded, since nothing between here and a card face has any use for
/// it.
@State private var thumbnails = AttachmentThumbnailCache()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
/// included, and no global or lane-local space is that.
@@ -149,6 +157,7 @@ struct BoardView: View {
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
}
.background(boardBackground)
.environment(thumbnails)
// The window-level fallback, *behind* the specific targets: a release over any in-window
// region they don't cover commits the current proposal instead of leaking the session into a
// cancel-snapback the drop lands where the shadows show, which is what the shadows promise.