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:
+152
-32
@@ -459,8 +459,8 @@ struct LaneView: View {
|
|||||||
drops: drops,
|
drops: drops,
|
||||||
openCard: openCard
|
openCard: openCard
|
||||||
)
|
)
|
||||||
case .placeholder:
|
case let .placeholder(phase):
|
||||||
NewCardStubView(store: store, openCard: openCard)
|
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
||||||
case let .shadow(_, height):
|
case let .shadow(_, height):
|
||||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
// 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.
|
// 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
|
// 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`),
|
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
|
||||||
// at the gesture for the placeholder, which touches no disk.
|
// 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))
|
.transition(Motion.cardTransition(reduced: reduceMotion))
|
||||||
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
|
// The scroll target. `ForEach` already carries this identity, but `scrollTo`
|
||||||
// resolves against an explicit `.id`, and it goes outermost so the transition
|
// 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
|
/// 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
|
/// `renderedCards`, and the placeholder's is shifted past a shadow run that opened in front of
|
||||||
/// it, so neither displaces the other.
|
/// 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] {
|
private var slots: [LaneSlot] {
|
||||||
var result = renderedCards.map(LaneSlot.card)
|
var result = renderedCards.map(LaneSlot.card)
|
||||||
|
|
||||||
let run = shadowRun
|
let run = shadowRun
|
||||||
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
|
let shadowPosition = run.map { min(max(0, $0.position), result.count) }
|
||||||
var placeholderPosition: Int?
|
var placeholder: (position: Int, phase: NewCardPlaceholder.Phase)?
|
||||||
if let placeholder = store.transient.newCardPlaceholder, placeholder.laneID == lane.id {
|
if let pending = store.transient.newCardPlaceholder, pending.laneID == lane.id {
|
||||||
placeholderPosition = BoardStore.insertionIndex(after: placeholder.anchorCardID, among: renderedCards)
|
let position = BoardStore.insertionIndex(after: pending.anchorCardID, among: renderedCards)
|
||||||
?? result.count
|
?? result.count
|
||||||
|
placeholder = (position, pending.phase)
|
||||||
}
|
}
|
||||||
|
|
||||||
let heights = run?.heights ?? []
|
let heights = run?.heights ?? []
|
||||||
@@ -594,9 +609,11 @@ struct LaneView: View {
|
|||||||
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
let shadows = heights.enumerated().map { LaneSlot.shadow(index: $0.offset, height: $0.element) }
|
||||||
result.insert(contentsOf: shadows, at: shadowPosition)
|
result.insert(contentsOf: shadows, at: shadowPosition)
|
||||||
}
|
}
|
||||||
if var position = placeholderPosition {
|
if var placeholder {
|
||||||
if let shadowPosition, position >= shadowPosition { position += heights.count }
|
if let shadowPosition, placeholder.position >= shadowPosition {
|
||||||
result.insert(.placeholder, at: min(position, result.count))
|
placeholder.position += heights.count
|
||||||
|
}
|
||||||
|
result.insert(.placeholder(placeholder.phase), at: min(placeholder.position, result.count))
|
||||||
}
|
}
|
||||||
return result
|
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).
|
/// 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
|
/// 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.
|
/// 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 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.
|
/// One of a drag's N contiguous shadows, at the dragged card's frozen height.
|
||||||
case shadow(index: Int, height: CGFloat)
|
case shadow(index: Int, height: CGFloat)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case let .card(card): Self.identity(of: card.id)
|
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
|
case let .placeholder(phase): Self.identity(ofPlaceholderIn: phase)
|
||||||
// keep its identity — and therefore its keyboard focus — while the user types.
|
|
||||||
case .placeholder: "placeholder"
|
|
||||||
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
// Constant per position in the run, so the shadows animate as slides when the proposal moves
|
||||||
// rather than blinking out and back in.
|
// rather than blinking out and back in.
|
||||||
case let .shadow(index, _): "shadow:\(index)"
|
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
|
/// 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.
|
/// disagree about what `scrollTo` is looking for.
|
||||||
static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" }
|
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
|
// 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
|
/// 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.
|
/// 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
|
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||||
/// treatment shootout).
|
/// treatment shootout).
|
||||||
private let stripeWidth: CGFloat = 4
|
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
|
||||||
titleRow
|
titleRow
|
||||||
carousel
|
carousel
|
||||||
}
|
}
|
||||||
@@ -790,7 +871,7 @@ private struct CardFaceView: View {
|
|||||||
// The band is deliberately *not* in the key, only in what renders — see `CardCarousel`.
|
// The band is deliberately *not* in the key, only in what renders — see `CardCarousel`.
|
||||||
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
|
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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
|
// 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.
|
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||||
.padding(.leading, stripeWidth)
|
.padding(.leading, stripeWidth)
|
||||||
@@ -1012,7 +1093,7 @@ private struct CardFaceView: View {
|
|||||||
// MARK: - Title row
|
// MARK: - Title row
|
||||||
|
|
||||||
private var titleRow: some View {
|
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))
|
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||||
.foregroundStyle(iconTint)
|
.foregroundStyle(iconTint)
|
||||||
.imageScale(.medium)
|
.imageScale(.medium)
|
||||||
@@ -1168,14 +1249,41 @@ private struct CardFaceView: View {
|
|||||||
/// Writer's create has run and the overlay is only covering the gap until the watcher round-trips
|
/// 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
|
/// 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.
|
/// 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 {
|
private struct NewCardStubView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
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
|
let openCard: (ItemID) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
switch phase {
|
||||||
if isEditing {
|
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(
|
InlineTitleField(
|
||||||
text: draft,
|
text: draft,
|
||||||
prompt: "Card title",
|
prompt: "Card title",
|
||||||
@@ -1190,24 +1298,36 @@ private struct NewCardStubView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
} else {
|
|
||||||
Text(store.transient.newCardPlaceholder?.draftTitle ?? "")
|
|
||||||
.font(.body)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(10)
|
.padding(CardFaceMetrics.contentPadding)
|
||||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isEditing: Bool {
|
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
|
||||||
store.transient.newCardPlaceholder?.phase == .editing
|
/// `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
|
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import Testing
|
|||||||
/// testable at all — `AnyTransition` is opaque).
|
/// testable at all — `AnyTransition` is opaque).
|
||||||
/// 3. **The named durations.** 03 fixes five figures by name; a silent drift in one of them would be
|
/// 3. **The named durations.** 03 fixes five figures by name; a silent drift in one of them would be
|
||||||
/// invisible in every other test in the suite.
|
/// invisible in every other test in the suite.
|
||||||
|
/// 4. **Which changes are not motion at all.** The create handoff has to "read as one arrival"
|
||||||
|
/// (02-architecture.md ▸ TransientBoardState ▸ overlays), and it achieves that by *removing* an
|
||||||
|
/// animation rather than adding one — the placeholder adopts the arriving card's `ForEach`
|
||||||
|
/// identity, so no transition has anything to fire on. The claim is a pure function of a
|
||||||
|
/// placeholder phase (`LaneSlot.identity(ofPlaceholderIn:)`), so it is pinned here rather than
|
||||||
|
/// watched for.
|
||||||
|
|
||||||
// MARK: - Which reloads perform
|
// MARK: - Which reloads perform
|
||||||
|
|
||||||
@@ -161,3 +167,87 @@ struct ReduceMotionVariantTests {
|
|||||||
#expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false))
|
#expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - The create handoff
|
||||||
|
|
||||||
|
/// **"The handoff must read as one arrival"** (02-architecture.md ▸ TransientBoardState ▸ overlays,
|
||||||
|
/// settled 2026-07-28): the new-card placeholder "is handed off by discarding itself the moment the
|
||||||
|
/// created card's UUID appears in a snapshot", and "the placeholder renders at the arriving card's
|
||||||
|
/// exact geometry/chrome".
|
||||||
|
///
|
||||||
|
/// The mechanism is `LaneSlot`'s identity function and nothing else — no `matchedGeometryEffect`, no
|
||||||
|
/// second transaction, no suppression flag. A committed placeholder is keyed by the *arriving card's*
|
||||||
|
/// slot id, so when the echo reload replaces the pseudo-card with the real one the `ForEach` element
|
||||||
|
/// is neither inserted nor removed: only its content changes, and `Motion.cardTransition` — attached
|
||||||
|
/// per slot in the lane's masonry — has nothing to run on. Two scale-and-fades become none.
|
||||||
|
///
|
||||||
|
/// That is the whole of what is unit-testable about it. The masonry is a `Layout`, the transition is
|
||||||
|
/// an opaque `AnyTransition`, and neither renders here — but the key derivation those two depend on
|
||||||
|
/// is a pure function of a phase, and it is the line that would have to break for the handoff to go
|
||||||
|
/// back to being two events.
|
||||||
|
struct CreateHandoffIdentityTests {
|
||||||
|
|
||||||
|
private let arriving = ItemID(rawValue: "6C1D4B4E-0000-4000-8000-00000000A001")
|
||||||
|
|
||||||
|
/// **The claim, stated as an equality.** The committed placeholder's key *is* the arriving card's
|
||||||
|
/// key — not a parallel spelling of it, the same function's answer — so nothing can drift the two
|
||||||
|
/// apart without failing here.
|
||||||
|
@Test func aCommittedPlaceholderIsKeyedAsTheArrivingCard() {
|
||||||
|
#expect(
|
||||||
|
LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving))
|
||||||
|
== LaneSlot.identity(of: arriving)
|
||||||
|
)
|
||||||
|
#expect(LaneSlot.placeholder(.awaitingArrival(arriving)).id == LaneSlot.identity(of: arriving))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other half of the handoff, from the slot the real card builds: the two `LaneSlot` cases
|
||||||
|
/// that stand for one card at two moments answer with one id, which is what makes them one
|
||||||
|
/// element to `ForEach` rather than two.
|
||||||
|
@Test func theArrivingCardAndItsPlaceholderAreOneElement() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
|
||||||
|
let snapshot = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
let card = try #require(snapshot.lanes.flatMap(\.cards).first)
|
||||||
|
|
||||||
|
#expect(LaneSlot.card(card).id == LaneSlot.placeholder(.awaitingArrival(card.id)).id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The editing phase keeps the constant key**, and that is not an oversight the handoff missed:
|
||||||
|
/// an open editor has no identity to adopt yet, and a key that changed while the user typed would
|
||||||
|
/// tear the field down mid-word and take its keyboard focus with it. The key changes exactly once,
|
||||||
|
/// at commit.
|
||||||
|
@Test func anOpenEditorKeepsItsConstantKey() {
|
||||||
|
#expect(LaneSlot.identity(ofPlaceholderIn: .editing) == LaneSlot.editingPlaceholderIdentity)
|
||||||
|
#expect(LaneSlot.placeholder(.editing).id == LaneSlot.editingPlaceholderIdentity)
|
||||||
|
// And it is emphatically *not* any card's key — that is the whole point of the one change.
|
||||||
|
#expect(LaneSlot.identity(ofPlaceholderIn: .editing) != LaneSlot.identity(of: arriving))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keys stay disjoint by construction — a shadow is never mistaken for the card arriving into
|
||||||
|
/// the slot it opened, and the editing placeholder is never mistaken for either.
|
||||||
|
@Test func theSlotNamespacesDoNotCollide() {
|
||||||
|
let keys: Set<String> = [
|
||||||
|
LaneSlot.identity(of: arriving),
|
||||||
|
LaneSlot.editingPlaceholderIdentity,
|
||||||
|
LaneSlot.shadow(index: 0, height: 44).id,
|
||||||
|
LaneSlot.shadow(index: 1, height: 44).id,
|
||||||
|
]
|
||||||
|
#expect(keys.count == 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Reduce Motion needs no branch of its own here.** Identity continuity introduces no motion to
|
||||||
|
/// reduce: the key is the same key in both variants, so the reduced path reads as one arrival for
|
||||||
|
/// exactly the reason the standard path does — a transition that never fires has no variant to
|
||||||
|
/// choose between. Asserted as the absence of a parameter, which is the shape the claim takes.
|
||||||
|
@Test func theHandoffIsIdenticalUnderReduceMotion() {
|
||||||
|
// The derivation takes no `reduced:` flag at all, and the transition it defeats has a reduced
|
||||||
|
// variant that is equally irrelevant while the element persists.
|
||||||
|
#expect(LaneSlot.identity(ofPlaceholderIn: .awaitingArrival(arriving)) == LaneSlot.identity(of: arriving))
|
||||||
|
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
|
||||||
|
#expect(Motion.cardAppearance(reduced: false) == .scaleAndFade(from: 0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user