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
+28
View File
@@ -105,6 +105,20 @@ enum Motion {
reduced ? nil : .snappy(duration: Duration.laneResize)
}
/// **The sole-selected card's carousel expanding and collapsing** 03-board-ui.md § Motion's
/// first named narrow key ("keyed narrowly on the sole-selected card (carousel expansion)").
///
/// The structural voice, because that is what the change *is*: the card grows, and the cards
/// below it in its masonry column slide down to make room. Nothing about it is a content reflow
/// the carousel's own arrival is a crossfade over the movement (`carouselAppearance`), and the
/// movement is positional.
///
/// It is spelled here rather than aliased at the call site so the figure stays one decision: if
/// the expansion ever wants a duration of its own, this is the line that changes.
static func carouselExpansion(reduced: Bool) -> Animation? {
structural(reduced: reduced)
}
/// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a
/// restore reads like the search filter leavers and arrivers run their transition, survivors
/// reflow under one gentle spring".
@@ -165,6 +179,20 @@ enum Motion {
laneAppearance(reduced: reduced).transition
}
/// **The attachment carousel arriving inside an already-drawn card** the one appearance in the
/// app with no reduced variant, because it is already the reduced form.
///
/// 03-board-ui.md fixes an appear/disappear *scale* for cards (~0.8) and lanes (~0.9) and for
/// nothing else, and inventing a third would be the per-site literal this type exists to
/// prevent. It is also unnecessary: the movement a carousel expansion performs is the card
/// growing, which `carouselExpansion` already animates the carousel itself only has to arrive
/// over it, and 10-accessibility.md's word for that is crossfade. So a Reduce Motion user gets
/// the same fade with the growth gone instant, which is exactly what the per-voice rule asks
/// for, one accessor rather than two.
static var carouselAppearance: Appearance { .crossfade }
static var carouselTransition: AnyTransition { carouselAppearance.transition }
// MARK: - The AppKit face
/// The lane-resize window animation's duration, for `NSAnimationContext` which takes a number