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
+21
View File
@@ -111,6 +111,16 @@ struct MotionCurveTests {
#expect(Motion.laneResizeWindowDuration == 0.2)
#expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration))
}
/// **The carousel expansion is the structural voice** (03-board-ui.md § Motion's first named
/// narrow key). What the change *is* is positional the card grows and its column-mates slide
/// down so it takes the general snappy spring rather than the filtering one. Pinned as an
/// identity with `structural` rather than as a literal, because if the two ever diverge it
/// should be a deliberate edit to one line.
@Test func theCarouselExpansionIsTheStructuralVoice() {
#expect(Motion.carouselExpansion(reduced: false) == Motion.structural(reduced: false))
#expect(Motion.carouselExpansion(reduced: false) != Motion.contentReflow(reduced: false))
}
}
// MARK: - Reduce Motion
@@ -125,6 +135,7 @@ struct ReduceMotionVariantTests {
#expect(Motion.delete(reduced: true) == nil)
#expect(Motion.laneResize(reduced: true) == nil)
#expect(Motion.contentReflow(reduced: true) == nil)
#expect(Motion.carouselExpansion(reduced: true) == nil)
}
/// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)."
@@ -139,4 +150,14 @@ struct ReduceMotionVariantTests {
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
}
/// **The carousel is the one appearance with no reduced variant, because it is already the
/// reduced form.** 03 fixes an appear/disappear scale for cards and lanes and for nothing else,
/// and the carousel needs none: the movement is the card growing, which `carouselExpansion`
/// animates and Reduce Motion makes instant, so the content only has to fade in over it. This
/// asserts the parameterless accessor is deliberate rather than an omission.
@Test func theCarouselAppearanceIsTheCrossfadeInBothVariants() {
#expect(Motion.carouselAppearance == .crossfade)
#expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false))
}
}