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:
@@ -15,6 +15,12 @@ import Testing
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -161,3 +167,87 @@ struct ReduceMotionVariantTests {
|
||||
#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