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:
+165
-45
@@ -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
|
||||
|
||||
@@ -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