diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 767eee3..6609892 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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 diff --git a/KanbanTests/MotionTests.swift b/KanbanTests/MotionTests.swift index a9a614d..efd6c4a 100644 --- a/KanbanTests/MotionTests.swift +++ b/KanbanTests/MotionTests.swift @@ -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 = [ + 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)) + } +}