Remove the face carousel — one card presentation

03's resettlement reverses the pathfinder carry-over: the
selection-keyed dual presentation proved undesirable, so a card has one
presentation — selection changes styling, never geometry, and the
masonry never reflows on click. Deleted the carousel view (page dots,
glass underlay, scroll-tick monitor), the QuickLook thumbnail cache
(sole consumer), the pure paging/suppression rules, and the
sole-selected animation key — Motion now keys transactions on the
search query and the drop proposal only. The attachment chip stays as
the face's whole attachment story; viewing media is the card window's
job. No carousel state had leaked beyond the view layer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:11:59 -04:00
parent 3f4125e324
commit 1020d9fca4
14 changed files with 47 additions and 1143 deletions
+23 -79
View File
@@ -27,8 +27,11 @@ import SwiftUI
/// ### The card face
///
/// `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`).
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and selection changes
/// only the face's styling never its geometry. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
/// a click, and viewing an attachment's media is the card window's job, not the face's
/// (03-board-ui.md § Card face).
struct LaneView: View {
let store: BoardStore
@@ -457,7 +460,6 @@ struct LaneView: View {
CardFaceView(
store: store,
card: card,
laneID: lane.id,
marquee: marquee,
drops: drops,
openCard: openCard
@@ -865,8 +867,7 @@ private enum CardFaceMetrics {
static let stripeWidth: CGFloat = 4
/// The plate's inset around its content.
static let contentPadding: CGFloat = 10
/// Between the icon, the title and the attachments chip and between the title row and the
/// carousel below it.
/// Between the icon, the title and the attachments chip.
static let rowSpacing: CGFloat = 6
}
@@ -893,34 +894,23 @@ private enum CardFaceMetrics {
/// 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).
///
/// ### The attachment carousel
/// ### One presentation, selection styling only
///
/// 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 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.
/// The face is a top-aligned title row and its two decorations the accent stripe and the
/// selection stroke are shapes in overlays. **A card has one presentation** (resettled
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
/// face's styling the selection stroke below and never its geometry, so the masonry never
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
/// attachment's media is the card window's job ( / double-click, 05-card-window.md), not the
/// face's the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
/// face).
private struct CardFaceView: View {
let store: BoardStore
let card: Card
/// 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`).
/// The strip's rubber band the registry this face registers its drawn frame into.
let marquee: MarqueeControl
/// The board window's drop machinery: this face registers its measured height into the geometry
@@ -932,10 +922,6 @@ 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.
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
@@ -947,18 +933,7 @@ private struct CardFaceView: View {
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
titleRow
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)
titleRow
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
@@ -1258,7 +1233,7 @@ private struct CardFaceView: View {
/// A value that resolves to nothing a typo'd palette name, a malformed hex, a sequence where
/// a scalar belongs draws **no stripe**, and the value stays on disk exactly as written.
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
/// content does, m5's carousel expansion included.
/// content does a title wrapping across its full four lines included.
@ViewBuilder
private var accentStripe: some View {
if let color = Palette.color(for: card.background) {
@@ -1270,37 +1245,6 @@ 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 {
@@ -1354,8 +1298,8 @@ private struct CardFaceView: View {
///
/// A newly created card is always default-styled `BoardWriter.createCard` writes `schema`, `title`
/// and `order` and nothing else so "the arriving card's chrome" is exactly: the level-default
/// symbol, the standard secondary tint, no accent stripe, no selection stroke (the commit re-selects
/// the *lane*), and no carousel (nothing is attached yet, and it is not the sole selection).
/// symbol, the standard secondary tint, no accent stripe, and no selection stroke (the commit
/// re-selects the *lane*).
private struct NewCardStubView: View {
let store: BoardStore
@@ -1459,8 +1403,8 @@ private struct NewCardStubView: View {
///
/// **`NewCardStubView.arrivingFace`'s precedent, applied to the drag**, and for the same reason: a
/// static rendition at the same numbers (`CardFaceMetrics`) is what makes the echo's swap invisible
/// rather than merely un-animated. It carries no gestures, no drop target, no geometry registration
/// and no carousel it stands in for exactly one round trip, and every surface that reads a card's
/// rather than merely un-animated. It carries no gestures, no drop target, and no geometry
/// registration it stands in for exactly one round trip, and every surface that reads a card's
/// drawn frame (the drop zones, the rubber band) is reading the *snapshot*'s cards, which this is
/// not one of.
///