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
+72 -17
View File
@@ -24,11 +24,11 @@ import SwiftUI
/// the quick-style recents row, the Width stepper and Delete 11-command-nexus.md Context menus'
/// Lane row, in its order, complete as of m5.
///
/// ### What is still a later card's
/// ### The card face
///
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
/// sole-selected card's attachment carousel.
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and the sole-selected
/// card expands its attachment carousel in place (`CardCarousel`).
struct LaneView: View {
let store: BoardStore
@@ -454,7 +454,8 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
registry: marquee.registry,
laneID: lane.id,
marquee: marquee,
drops: drops,
openCard: openCard
)
@@ -724,21 +725,35 @@ private enum LaneSlot: Identifiable {
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
///
/// ### Room for the carousel
/// ### The attachment carousel
///
/// The face is a top-aligned `VStack` and its two decorations the accent stripe and the selection
/// stroke are shapes in overlays, so both stretch to whatever height the content takes. That is
/// what lets m5's carousel expand *inside* this card without any of it being re-derived: the
/// masonry already isolates column heights, so a taller card pushes only the cards below it in its
/// own column.
/// what lets the carousel expand *inside* this card without any of it being re-derived: the masonry
/// already isolates column heights, so a taller card pushes only the cards below it in its own
/// column, and every consumer of a card's size reads a **live registered frame** rather than a
/// nominal one the drop model's resting grid (`onGeometryChange` into `LaneDropRegistry`) and the
/// rubber band's sweep universe (`marqueeTarget`) both re-register the moment the card grows, so
/// neither needed a word about the expansion.
///
/// Who expands and when is `CardCarousel`'s, and it is the whole of what this view decides here:
/// `soleSelectedCardID` is the narrow animation key (03-board-ui.md § Motion), `expandedCardID` is
/// the same answer with the rubber band's suppression on top, and the difference between them is
/// why a band never animates anything.
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame
/// here and takes it out again when it leaves see `View.marqueeTarget`.
let registry: MarqueeTargetRegistry
/// The lane this face is drawn in the middle component of the card's folder path, which is
/// what the carousel needs to reach `attachments/`. Passed rather than looked up: the lane
/// rendering this face already knows, and a walk of the snapshot per card face to re-learn it
/// would be the board's own layout asking the board where its cards are.
let laneID: ItemID
/// The strip's rubber band the registry this face registers its drawn frame into, and the
/// session whose in-flight band suppresses the carousel (`CardCarousel.expanded`).
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
@@ -749,6 +764,10 @@ private struct CardFaceView: View {
/// The app-wide quick-style recents see `LaneView`'s own note.
@Environment(AppModel.self) private var appModel
/// Reduce Motion, for the carousel's expansion and its page slides (10-accessibility.md). Read
/// from the environment and handed to `Motion`, which owns what "reduced" means.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// The plate's corner radius shared with the accent stripe, which rounds its left corners to
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
private let cornerRadius: CGFloat = 8
@@ -760,11 +779,16 @@ private struct CardFaceView: View {
var body: some View {
VStack(alignment: .leading, spacing: 6) {
titleRow
// m5-carousel: the sole selected card's paged attachment carousel expands here below
// the title, inside this same plate, keyed on the selection transaction
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
// view's `isSelected`; nothing above it changes.
carousel
}
// **The narrow transaction** (03-board-ui.md § Motion: "animated transactions are keyed
// narrowly on the sole-selected card (carousel expansion) never on broad state like the
// selection set"). The key is the sole selection and nothing else: a multi-select's churn
// never changes it, so multi-select churn never animates the same bullet's other half,
// held by construction rather than by suppression.
//
// The band is deliberately *not* in the key, only in what renders see `CardCarousel`.
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
@@ -819,7 +843,7 @@ private struct CardFaceView: View {
drops.registry.update(height: height, for: card.id)
}
.onDisappear { drops.registry.removeHeight(card.id) }
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
.contextMenu { cardMenu }
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
@@ -1073,6 +1097,37 @@ private struct CardFaceView: View {
}
}
// MARK: - The attachment carousel
/// The paged carousel, when this card is the entire selection and has files to page through
/// (03-board-ui.md § Card face) below the title, inside this same plate, adding height and
/// changing nothing above it.
///
/// **An untitled card gets it identically**: the placeholder is what sits above, and 03 makes
/// titles optional at every level without qualifying anything that reads them.
@ViewBuilder
private var carousel: some View {
if CardCarousel.expands(card, expanded: expandedCardID) {
AttachmentCarousel(
pages: CardCarousel.pages(of: card, boardRoot: store.rootURL, laneID: laneID)
)
.transition(Motion.carouselTransition)
}
}
/// **The animation key** the sole-selected card, band or no band. See `CardCarousel`: keeping
/// the marquee out of the key is what makes a band's arrival and departure change the face
/// without easing anything, which is 03 § Motion's animation-free-by-construction rule for the
/// marquee applied to the one surface its churn could otherwise animate.
private var soleSelectedCardID: ItemID? {
CardCarousel.soleSelection(store.selection)
}
/// What actually expands: the key, suppressed for as long as a rubber band is in flight.
private var expandedCardID: ItemID? {
CardCarousel.expanded(store.selection, marqueeActive: marquee.session.isActive)
}
// MARK: - Selection and rename plumbing
private var isSelected: Bool {