Make the card-create handoff read as one arrival

The committed placeholder's lane slot now takes the arriving card's
identity — .awaitingArrival keys as card:<id>, so when the echo reload
lands the ForEach sees one persisting element whose content swaps from
stub to card face instead of a removal and an insertion with two
scale+fade transitions (DESIGN/02 > overlays: the handoff must read as
one arrival). The awaiting face renders the card's exact chrome from
shared CardFaceMetrics, so any residual branch crossfade is between
pixel-identical renderings; the editing phase keeps its constant key so
typing identity holds. Slot position math is untouched.
CreateHandoffIdentityTests pins the key derivation.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 07:15:39 -04:00
parent e6d3673891
commit 15006ad233
2 changed files with 255 additions and 45 deletions
+165 -45
View File
@@ -459,8 +459,8 @@ struct LaneView: View {
drops: drops,
openCard: openCard
)
case .placeholder:
NewCardStubView(store: store, openCard: openCard)
case let .placeholder(phase):
NewCardStubView(store: store, phase: phase, openCard: openCard)
case let .shadow(_, height):
// One of the drag's N contiguous shadows, at the dragged card's frozen
// height the run's real footprint, so the drop lands exactly here.
@@ -474,6 +474,15 @@ struct LaneView: View {
// too: it is the card, one round trip early. Whether any of it *performs* is
// decided upstream at the reload for the real cards (`Motion.reloadAnimates`),
// at the gesture for the placeholder, which touches no disk.
//
// **The create handoff deliberately never reaches it.** A committed placeholder
// is already keyed by the arriving card's identity (`LaneSlot`), so when the echo
// reload swaps the pseudo-card for the real one the `ForEach` element is neither
// inserted nor removed only its content changes and an appear/disappear
// transition has nothing to run on. That is the whole of "the handoff must read
// as one arrival" (02-architecture.md TransientBoardState overlays), and it
// holds identically under Reduce Motion: a transition that does not fire has no
// variant to choose between.
.transition(Motion.cardTransition(reduced: reduceMotion))
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
// resolves against an explicit `.id`, and it goes outermost so the transition
@@ -578,15 +587,21 @@ struct LaneView: View {
/// cannot appear anywhere but where the real card lands. Both insertions are computed against
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
/// it, so neither displaces the other.
///
/// **The phase rides along**, because it is what keys the slot: a committed placeholder wears the
/// 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.
private var slots: [LaneSlot] {
var result = renderedCards.map(LaneSlot.card)
let run = shadowRun
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
var placeholderPosition: Int?
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
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)
?? result.count
placeholder = (position, pending.phase)
}
let heights = run?.heights ?? []
@@ -594,9 +609,11 @@ struct LaneView: View {
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
result.insert(contentsOf: shadows, at: shadowPosition)
}
if var position = placeholderPosition {
if let shadowPosition, position >= shadowPosition { position += heights.count }
result.insert(.placeholder, at: min(position, result.count))
if var placeholder {
if let shadowPosition, placeholder.position >= shadowPosition {
placeholder.position += heights.count
}
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
}
return result
}
@@ -679,18 +696,47 @@ private struct ShadowRun: Equatable {
/// title commits (02-architecture.md § Layering, the one named exception to the one-way flow).
/// Modelling it as a sibling case rather than as a fake `Card` is what keeps that true nothing can
/// accidentally hand it to code expecting an item that exists.
private enum LaneSlot: Identifiable {
///
/// ### The create handoff, which is entirely a question of `id`
///
/// 02-architecture.md TransientBoardState overlays (settled, 2026-07-28) requires the handoff to
/// **read as one arrival**: "the placeholder renders at the arriving card's exact geometry/chrome".
/// The mechanism is this enum's identity function and nothing else no `matchedGeometryEffect`, no
/// second transaction, no suppression flag.
///
/// A placeholder carries its phase, and the phase decides its key:
///
/// - **`.editing`** keys to the constant `"placeholder"`. There is only ever one placeholder in one
/// lane at a time, and it must hold its identity and therefore its keyboard focus for as long
/// as the user types.
/// - **`.awaitingArrival(id)`** keys to `identity(of: id)`: **the very key the arriving card will
/// use**. The Writer's create has run, so the real card's UUID exists a full round trip before its
/// `Card` does, and adopting it early is what makes the handoff a *content swap inside one
/// `ForEach` element* rather than a removal and an insertion at coincident slots. The element
/// persists across the echo reload, so `Motion.cardTransition` attached per slot in the masonry
/// never fires on it: one arrival, one geometry, no double scale-and-fade.
///
/// The key therefore changes exactly once, at commit, and that change is deliberately outside every
/// animated transaction the board runs (`BoardStore.commitPlaceholder` is a plain synchronous call
/// from the editor's Return; `land`'s `withAnimation` comes a round trip later), so it costs no
/// motion either.
///
/// Two slots can never collide on the arriving key: `BoardStore.land` assigns the snapshot and
/// re-grounds the transient state the discard-on-arrival among it inside one transaction, so no
/// render pass ever sees both the real card and the placeholder standing in for it.
enum LaneSlot: Identifiable {
case card(Card)
case placeholder
/// The new-card placeholder, carrying the phase that decides both what it draws and what it is
/// keyed by. Passed down rather than re-read in the stub so the key and the face cannot disagree
/// about which half of the handoff this is.
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)
var id: String {
switch self {
case let .card(card): Self.identity(of: card.id)
// Constant, because there is only ever one placeholder in one lane at a time and it must
// keep its identity and therefore its keyboard focus while the user types.
case .placeholder: "placeholder"
case let .placeholder(phase): Self.identity(ofPlaceholderIn: phase)
// 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)"
@@ -700,6 +746,39 @@ private enum LaneSlot: Identifiable {
/// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot
/// disagree about what `scrollTo` is looking for.
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
/// The key an open editor holds while it has no identity of its own to hold.
static let editingPlaceholderIdentity = "placeholder"
/// **The handoff, as one pure function.** See the type's doc comment: a committed placeholder
/// answers with the arriving card's key, which is what makes the swap continuous.
static func identity(ofPlaceholderIn phase: NewCardPlaceholder.Phase) -> String {
switch phase {
case .editing: editingPlaceholderIdentity
case let .awaitingArrival(id): identity(of: id)
}
}
}
// MARK: - The card plate's metrics
/// The card plate's geometry, spelled once because **two views draw it**: the real face
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
/// `.awaitingArrival` face). 02-architecture.md TransientBoardState overlays makes the handoff
/// "read as one arrival the placeholder renders at the arriving card's exact geometry/chrome", and
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
private enum CardFaceMetrics {
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static let cornerRadius: CGFloat = 8
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
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.
static let rowSpacing: CGFloat = 6
}
// MARK: - Card face
@@ -770,14 +849,16 @@ private struct CardFaceView: View {
/// 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
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
/// draw this same plate for the create handoff to read as one arrival.
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
/// K1 · left edge stripe (03-board-ui.md § Styling Capabilities, settled in the pathfinder's
/// treatment shootout).
private let stripeWidth: CGFloat = 4
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
titleRow
carousel
}
@@ -790,7 +871,7 @@ private struct CardFaceView: View {
// 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)
.padding(CardFaceMetrics.contentPadding)
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
// colouring a card never shifts its title relative to its uncoloured neighbours.
.padding(.leading, stripeWidth)
@@ -1012,7 +1093,7 @@ private struct CardFaceView: View {
// MARK: - Title row
private var titleRow: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
.foregroundStyle(iconTint)
.imageScale(.medium)
@@ -1168,46 +1249,85 @@ private struct CardFaceView: View {
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
/// the real card; leaving a live field there would invite edits that have nowhere to go, and its
/// focus loss would fire the discard rule against a card that is already on its way.
///
/// ### The two faces are two *different* faces, on purpose
///
/// The editing face is an editor the accent-stroked well the user is typing into. The awaiting
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
/// new card gets (`CardFaceView`, via the `CardFaceMetrics` both read). That is the second half of
/// 02-architecture.md TransientBoardState overlays' one-arrival rule the first half is
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
/// reload a content swap inside one persistent element; drawing that content identically is what
/// makes the swap invisible rather than merely un-animated.
///
/// 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).
private struct NewCardStubView: View {
let store: BoardStore
/// How far along the birth is handed down from the slot rather than re-read from the store, so
/// the face this view draws and the identity the slot is keyed by are the same answer.
let phase: NewCardPlaceholder.Phase
let openCard: (ItemID) -> Void
var body: some View {
Group {
if isEditing {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { commit() },
onAbandon: { store.transient.discardPlaceholder() },
onFocusLoss: { store.transient.discardPlaceholder() },
onCommitAndOpen: {
// The one board command that stays enabled mid-edit: commit, then open
// (04 Grammar's carve-out). A commit that discarded empty title, a
// vanished lane, a failed create hands back no id and opens nothing.
if let id = commit() { openCard(id) }
}
)
.font(.body)
} else {
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(4)
}
switch phase {
case .editing: editor
case .awaitingArrival: arrivingFace
}
}
/// The editor well an accent-stroked plate around the field, which is what a thing being typed
/// into should look like and deliberately not what a card looks like.
private var editor: some View {
InlineTitleField(
text: draft,
prompt: "Card title",
onCommit: { commit() },
onAbandon: { store.transient.discardPlaceholder() },
onFocusLoss: { store.transient.discardPlaceholder() },
onCommitAndOpen: {
// The one board command that stays enabled mid-edit: commit, then open
// (04 Grammar's carve-out). A commit that discarded empty title, a
// vanished lane, a failed create hands back no id and opens nothing.
if let id = commit() { openCard(id) }
}
)
.font(.body)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.padding(CardFaceMetrics.contentPadding)
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
)
}
private var isEditing: Bool {
store.transient.newCardPlaceholder?.phase == .editing
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
/// (`CardFaceMetrics`) when the echo reload swaps this view for the real face inside the one
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
///
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
private var arrivingFace: some View {
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
Image(systemName: ItemSymbol.card)
.foregroundStyle(.secondary)
.imageScale(.medium)
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
.font(.body)
.lineLimit(4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardFaceMetrics.contentPadding)
.padding(.leading, CardFaceMetrics.stripeWidth)
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
}
/// Commits, then **re-selects the lane** "Return commits and re-selects the lane (next Return