Back out the render-at-release drop presentation
The settle rule 3f4125e introduced — at release the shadow swaps for the
dropped card(s) drawn in place immediately — is withdrawn on review. The
board returns to the presentation the committed-overlay hold has always
had: the shadows keep standing at their landing slot and the originals
keep their lift until the echo reload lands, whatever the operation was,
and the real faces arrive with the snapshot that carries the write.
Gone with it: the DropLanding/DroppedItem seam and the three landing
accessors, the hold's landing/operation payload and the removesOriginals
and keepsIdentity halves it decided, the pickup-captured titles a
cross-board arrival's face needed, LaneSlot.dropped with DroppedFace and
DroppedCardFace, the strip's dropped-lane run, and the trash column's
settled tombstone rows — the same presentation at the delete gesture's
end, which goes for the same reason while the drop-to-delete write and
its shadow row stay exactly as they were.
Kept: the hold itself, which predates the commit, along with the 1500 ms
failed-write timeout, its injectable duration and its extracted expire,
and the guard that keeps a settled release past retargeting. The tests
pinning those stay; the ones asserting what the landing slot drew are
gone, and the suite now pins the hold's own claim — the arrangement does
not move at the release.
The pause between the release and the echo is therefore an open design
question again, filed on the Redesign board.
Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
+19
-197
@@ -377,9 +377,6 @@ struct LaneView: View {
|
||||
drops.session.beginLanes(
|
||||
members.map(\.id),
|
||||
folders: payload.folders,
|
||||
// What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's
|
||||
// own titles, so the session and the pasteboard cannot disagree about what travelled.
|
||||
titles: payload.items.map(\.title),
|
||||
// The dragged items' own sizes, frozen at drag start — the one thing that is
|
||||
// (03-board-ui.md § Motion).
|
||||
units: members.map { LaneLayoutMath.displayUnits(of: $0) },
|
||||
@@ -487,10 +484,6 @@ struct LaneView: View {
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
.frame(height: height)
|
||||
case let .dropped(face):
|
||||
// The same run, one instant later: the release has settled and the
|
||||
// dropped card is drawn where its shadow was (`DroppedCardFace`).
|
||||
DroppedCardFace(card: face.card, title: face.title)
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
@@ -575,16 +568,13 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a card drag lands **in this lane** and what that slot draws — a run of shadows while the
|
||||
/// drag is in flight, the dropped cards themselves once the release has settled
|
||||
/// (`DragSession.cardLanding`). `nil` when the proposal is elsewhere.
|
||||
private var cardLanding: DropLanding? {
|
||||
drops.session.cardLanding(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||
/// Where a card drag would land **in this lane**, or `nil` when the proposal is elsewhere.
|
||||
private var cardProposal: Int? {
|
||||
drops.session.laneProposal(onBoardRooted: store.rootURL, laneID: lane.id)
|
||||
}
|
||||
|
||||
/// The run's **geometry** — where it opens and what each of its slots is worth in height — or
|
||||
/// `nil` when no proposal names this lane. The masonry's one make-room mechanism, and the
|
||||
/// reflow's narrow animation key.
|
||||
/// The shadow run this lane opens, or `nil` when no proposal names it — the masonry's one
|
||||
/// make-room mechanism, and the reflow's narrow animation key.
|
||||
///
|
||||
/// Two sessions feed it and they are mutually exclusive by construction (a file session never
|
||||
/// arms `DragSession`, so `isActive` is false for exactly as long as one is in flight):
|
||||
@@ -593,14 +583,9 @@ struct LaneView: View {
|
||||
/// drop lands exactly where the shadows are;
|
||||
/// - **a Finder file drag**, at the nominal height, one shadow per file — the cards being
|
||||
/// proposed do not exist yet, so there is no measured height to be faithful to.
|
||||
///
|
||||
/// **Computed identically on both sides of a release**, deliberately: the settle changes what
|
||||
/// the run's slots *contain*, never where they are or how much room they take, so this value —
|
||||
/// the animation key — does not move at the drop. That is what makes the un-hide instant
|
||||
/// rendering rather than motion (03-board-ui.md § Motion), with no suppression flag anywhere.
|
||||
private var shadowRun: ShadowRun? {
|
||||
if let cardLanding {
|
||||
return ShadowRun(position: cardLanding.index, heights: drops.session.cardHeights)
|
||||
if let position = cardProposal {
|
||||
return ShadowRun(position: position, heights: drops.session.cardHeights)
|
||||
}
|
||||
if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootURL, laneID: lane.id) {
|
||||
return ShadowRun(
|
||||
@@ -625,15 +610,11 @@ struct LaneView: View {
|
||||
/// arriving card's identity so the handoff is one arrival rather than two (`LaneSlot`). The
|
||||
/// position math above is untouched by that — the key changes, the index does not — so the
|
||||
/// masonry cannot flinch at the moment of commit.
|
||||
///
|
||||
/// The drag's run is the same story told at the other end: its slots change from shadows to the
|
||||
/// dropped cards at release, at the same position and the same count, so nothing in this function
|
||||
/// moves when a drop settles (`runSlots`).
|
||||
private var slots: [LaneSlot] {
|
||||
var result = renderedCards.map(LaneSlot.card)
|
||||
|
||||
let run = shadowRun
|
||||
let runPosition = run.map { min(max(0, $0.position), result.count) }
|
||||
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
|
||||
var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
|
||||
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
|
||||
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
|
||||
@@ -641,72 +622,30 @@ struct LaneView: View {
|
||||
placeholder = (position, pending.phase)
|
||||
}
|
||||
|
||||
let inserted = runSlots(run)
|
||||
if let runPosition {
|
||||
result.insert(contentsOf: inserted, at: runPosition)
|
||||
let heights = run?.heights ?? []
|
||||
if let shadowPosition {
|
||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||
}
|
||||
if var placeholder {
|
||||
if let runPosition, placeholder.position >= runPosition {
|
||||
placeholder.position += inserted.count
|
||||
if let shadowPosition, placeholder.position >= shadowPosition {
|
||||
placeholder.position += heights.count
|
||||
}
|
||||
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// What the run at the proposal is made of — **the settle, as one branch**.
|
||||
///
|
||||
/// While the drag is in flight it is N dashed outlines at the dragged cards' frozen heights. The
|
||||
/// instant the release commits it is the cards themselves: "at release the shadow is replaced by
|
||||
/// the dropped card(s) drawn in place immediately, the appear never waiting for the echo — a
|
||||
/// lingering shadow over a hidden card is the hold failing its one job" (03-board-ui.md § Motion,
|
||||
/// sharpened 2026-07-28).
|
||||
///
|
||||
/// Where each face's content comes from is `DropLanding.Dropped.isLocal`'s answer: a within-board
|
||||
/// landing is a card this snapshot still has — at its pre-drop position, or in the trash for a
|
||||
/// restore — so its **real** face travels to the landing slot, and a cross-board arrival has only
|
||||
/// the title it travelled under until the echo brings the rest (`DroppedCardFace`).
|
||||
private func runSlots(_ run: ShadowRun?) -> [LaneSlot] {
|
||||
guard let run else { return [] }
|
||||
guard case let .dropped(drop) = cardLanding?.run else {
|
||||
return run.heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||
}
|
||||
return drop.items.enumerated().map { index, item in
|
||||
LaneSlot.dropped(DroppedFace(
|
||||
index: index,
|
||||
id: item.id,
|
||||
card: drop.isLocal ? snapshotCard(item.id) : nil,
|
||||
title: item.title,
|
||||
keepsIdentity: drop.keepsIdentity
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The dropped item as this board already knows it, tombstones included — a restore's card is in
|
||||
/// the snapshot exactly as a moved one is, only on the other side of the live/trash boundary.
|
||||
/// `nil` for an arrival this board has never held.
|
||||
private func snapshotCard(_ id: ItemID) -> Card? {
|
||||
for lane in store.snapshot.lanes {
|
||||
if let card = lane.cards.first(where: { $0.id == id }) { return card }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the
|
||||
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
|
||||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||
/// tombstoned lane at all.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the drag is in flight.** It is lifted
|
||||
/// out of the resting layout at pickup and stays out until release *whatever the effective
|
||||
/// operation is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released
|
||||
/// mid-drag, and a layout that re-admitted them on every flip would flap the board under the
|
||||
/// cursor (DRAG-REORDER.md § Resting-layout zones).
|
||||
///
|
||||
/// **At release the lift ends** (`DragSession.hiddenMembers`): a settled copy's originals are
|
||||
/// back in this list in the same render pass the copies appear at the landing slot, and a settled
|
||||
/// move's stay out because the overlay is now drawing them *there* rather than here (`runSlots`).
|
||||
/// Either way nothing on this board is hidden behind a shadow once the mouse is up.
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
/// is* — a ⌥-copy's originals really do stay, but ⌥ can be pressed and released mid-drag, and a
|
||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||
///
|
||||
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
|
||||
/// "cards whose title *and* body both miss the query animate out". This is the one collection
|
||||
@@ -844,9 +783,6 @@ enum LaneSlot: Identifiable {
|
||||
case placeholder(NewCardPlaceholder.Phase)
|
||||
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||
case shadow(index: Int, height: CGFloat)
|
||||
/// One of the **dropped** cards, drawn in its landing slot from the instant of release until the
|
||||
/// echo reload brings the real one (`DroppedFace`).
|
||||
case dropped(DroppedFace)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -855,12 +791,6 @@ enum LaneSlot: Identifiable {
|
||||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||
// rather than blinking out and back in.
|
||||
case let .shadow(index, _): "shadow:\(index)"
|
||||
// **The placeholder's handoff, again.** A move keeps the identity it travelled under, so the
|
||||
// slot wears the arriving card's own key and the echo reload swaps content inside one
|
||||
// element — no removal, no insertion, no transition to fire. A copy mints a fresh GUID and a
|
||||
// cross-board arrival may be reminted at the import boundary, so neither can promise a key:
|
||||
// theirs is positional, and the real card's arrival reads as the arrival it is.
|
||||
case let .dropped(face): face.keepsIdentity ? Self.identity(of: face.id) : "landing:\(face.index)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,25 +811,6 @@ enum LaneSlot: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One dropped card as its landing slot draws it, for the round trip between the release and the
|
||||
/// echo (03-board-ui.md § Motion ▸ the drop settle).
|
||||
///
|
||||
/// `card` is the item as **this** board already holds it — a within-board landing, whose real face
|
||||
/// simply moves to the landing slot. A cross-board arrival has none, and `title` is what it
|
||||
/// travelled under (`DroppedItem`): enough for a face, and everything the destination can honestly
|
||||
/// say before the write round-trips.
|
||||
struct DroppedFace {
|
||||
/// Position within the run — the positional key's whole content, for a landing that cannot
|
||||
/// promise an identity.
|
||||
var index: Int
|
||||
var id: ItemID
|
||||
var card: Card?
|
||||
var title: String?
|
||||
/// Whether the arriving card will wear `id`, and therefore whether this slot may key by it —
|
||||
/// see `LaneSlot.id`.
|
||||
var keepsIdentity: Bool
|
||||
}
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **two views draw it**: the real face
|
||||
@@ -1097,9 +1008,6 @@ private struct CardFaceView: View {
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// What a cross-board arrival's overlay has to draw with (`DroppedItem`) — the payload's
|
||||
// own titles, so the session and the pasteboard cannot disagree about what travelled.
|
||||
titles: payload.items.map(\.title),
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
@@ -1442,92 +1350,6 @@ private struct NewCardStubView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The dropped card
|
||||
|
||||
/// **A dropped card, drawn at the instant of release** — 03-board-ui.md § Motion, sharpened
|
||||
/// 2026-07-28: "at release the shadow is replaced by the dropped card(s) drawn in place immediately,
|
||||
/// the appear never waiting for the echo (a lingering shadow over a hidden card is the hold failing
|
||||
/// its one job)". The system drag image's fade then dissolves over a card that is already there,
|
||||
/// which is the whole promise the settle makes.
|
||||
///
|
||||
/// **`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, 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.
|
||||
///
|
||||
/// Two sources, one face (`DroppedFace`): a within-board landing draws the card the snapshot still
|
||||
/// holds — icon, tint, stripe, attachments and all, so a move looks like the very card that was
|
||||
/// picked up — and a cross-board arrival draws the title it travelled under under the level-default
|
||||
/// symbol, because that is all the destination knows until the write lands.
|
||||
private struct DroppedCardFace: View {
|
||||
|
||||
let card: Card?
|
||||
let title: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(displayTitle ?? "Untitled")
|
||||
.font(.body)
|
||||
.foregroundStyle(displayTitle == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.padding(.leading, CardFaceMetrics.stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
// Inert, deliberately: the write naming this slot is already in flight, and a face that
|
||||
// answered clicks would be offering to act on an item whose identity is a round trip away.
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// The card's own title, or the one it travelled under — `nil` means untitled either way, and
|
||||
/// "Untitled" is a rendering rather than a value (03-board-ui.md § Card face).
|
||||
private var displayTitle: String? { card?.title.value ?? title }
|
||||
|
||||
private var symbol: String {
|
||||
guard let card else { return ItemSymbol.card }
|
||||
return ItemSymbol.name(card.icon, fallback: ItemSymbol.card)
|
||||
}
|
||||
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let card, let color = Palette.color(for: card.iconColor) {
|
||||
AnyShapeStyle(color)
|
||||
} else {
|
||||
AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let card, let color = Palette.color(for: card.background) {
|
||||
UnevenRoundedRectangle(
|
||||
topLeadingRadius: CardFaceMetrics.cornerRadius,
|
||||
bottomLeadingRadius: CardFaceMetrics.cornerRadius
|
||||
)
|
||||
.fill(color)
|
||||
.frame(width: CardFaceMetrics.stripeWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if let card, !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The inline title field
|
||||
|
||||
/// The one text field all three inline editors wear — the new-card placeholder, a card rename, and
|
||||
|
||||
Reference in New Issue
Block a user