Animation Design Plan — Deck-Builder blueprint-morph + card-flight
Stage 2 review alignment page for /animation-design-planner. Planning run only: design/review artifacts, no implementation code, no new dependencies.
docs/history/archive/2026-06-10/173258/alignment/docs/history/archive/2026-06-10/174721/alignment/research/skills-showcase/_working/preliminary-animation-design-planner-research.md (non-canonical)apps/skills-showcase/docs/animation-plan-deck-builder.md — written only after approvalapps/skills-showcase/docs/deck-builder-ux.md (commit 6e2e6aec)1. Context & Evidence
The deck-builder UX design (apps/skills-showcase/docs/deck-builder-ux.md, committed 6e2e6aec) introduces two new framer-motion transitions that don't exist in the /prototype flow:
- blueprint-morph — deck blueprint card on the table ⇄ builder overlay at
/deck/[slug]. - card-flight — fan card flies into a deck slot on tap, plus an add-all stagger variant.
/animation-design-planner was invoked to plan these before building — naming the visible motion contracts, lifecycle owner, state phases, and proof method so implementation doesn't start with local timing tweaks. Framework detected: framer-motion 12.40 (imported as "framer-motion") → motion-framer subskill guardrails apply.
Evidence behind the load-bearing decisions
| Claim / decision | Evidence (observed) | Inference | Confidence |
|---|---|---|---|
| Both transitions are new compositions, not new primitives | docs/deck-builder-ux.md design principle 3 ("Promote, don't rewrite"); existing primitives in SealedPack.tsx, PackOpener.tsx, SkillCard.tsx, BottomSheet | The plan reuses layoutId morph + collapse-to-target motion; no new animation library surface needed | High |
layoutId + declarative animate on transforms conflict | PackOpener.tsx:212-213 — existing workaround in the pack flow | Flight clones must be driven imperatively with motion values; slot cards (declarative pulses) never carry a layoutId | High (repo-observed) |
| Route-first close would break the morph-back | App Router route-change unmounts are not AnimatePresence-aware (framework behavior) | Client-state-first with shallow pushState is required; real /deck/[slug] route kept for hard loads/SEO | Medium — spike required on Next 16.2.6 (see risk register) |
| Sheet children cannot paint above the slot strip | BottomSheet's z-50 stacking context in the prototype flow | Card-flight needs a portal clone overlay (FlightLayer) rather than unclipping the sheet | High |
2. Motion Contract A — blueprint-morph
| Trigger | Tap/click a deck blueprint on the table (/); close via Back button or dismiss affordance. |
|---|---|
| Start → end | Small blueprint card on the table → full builder panel (/deck/[slug]) with phase slots; reverse on close. URL changes both ways. |
| Focal / continuity element | The blueprint card chrome is the single visually continuous rectangle (shared layoutId="deck-blueprint-${slug}"); builder content (slot columns, header, shelf glow) staggers in after the morph lands. |
Never list
- Never: double-vision — blueprint and panel both visible at full opacity.
- Never: flash at morph-back.
- Never: builder content popping before the morph completes.
- Never: table scroll jump.
- Never: focus loss — focus moves to the builder heading on open, returns to the originating blueprint on close.
- Never: URL/visual desync.
Interruption rules
- Blueprint taps ignored unless phase is
table. - Dismiss legal only from
builder-open— plus dismiss-during-blueprint-morphing, which retargets the layout morph from its current projected position. - Hard load of
/deck/[slug]skips the open morph (no origin) but still morphs back on close, because the table renders underneath.
Gate: blueprint-morph contract acceptance
Do you accept the blueprint-morph visible motion contract (trigger, continuity element, never list, interruption rules) as the binding spec for implementation?
3. Motion Contract B — card-flight
| Trigger | Tap an uncollected card in the open fan drawer; "Collect all N" triggers the staggered batch variant. |
|---|---|
| Start → end | Card visually leaves the fan, flies above the sheet to its phase slot, slot fills and pulses once, deck counter ticks; the fan card is dimmed with an "in deck ♦" badge from the tap frame onward. |
| Focal element | The flying clone; the slot is the continuity target (pinned visible above the sheet on mobile). |
Never list
- Never: double-vision — full-opacity fan card while the clone flies.
- Never: clone clipped by the sheet's overflow or painted under the scrim/slot strip.
- Never: slot filling before the clone lands (reduced motion excepted).
- Never: counter desync after interruptions.
- Never: accidental collect from an inspect tap — flip and fly remain separate affordances.
Interruption rules
- Re-tap of an in-flight or collected card is a no-op.
- Concurrent flights of different cards are allowed.
- Drawer close or builder dismiss mid-flight snaps all flights to end (no cancel/rollback) before proceeding.
- Deck-complete sequence defers until all flights land.
Gate: card-flight contract acceptance
Do you accept the card-flight visible motion contract (trigger, focal element, never list, interruption rules), including the add-all stagger variant, as the binding spec for implementation?
4. Interactive: Motion Demos & Phase Timeline Scrubbers
- Demo scope: yes — the demo shows the card movement flow only. The 1:1 card→slot mirror is a demo simplification, not the deck model. This animation plan binds how cards travel, not which slots exist.
- What the locked UX design actually specifies (
deck-builder-ux.md): ghost slots are suggestions, not requirements — a filled slot can be swapped, and replacing a suggestion converts the deck to a custom variant with an editable name and explicit CLI output. A freeform overlay row accepts any card. [Custom deck +] creates a deck with empty freeform phases — user-defined building exists there. Add-all ("Collect all N for this deck") exists precisely to kill the chore on big decks (13-cardbusiness-opsis one tap). Canonical decks mirror their card lists deliberately: the locked decision was "the deck teaches the pipeline as you build," and the build ritual converges on unlocking theinstall-deckcommand. - Open question routed out of this plan: whether canonical decks should loosen toward user-defined phases challenges the locked "phase-labeled slots vs freeform tray" UX decision — that belongs to the deck-creation user-flow design (
/user-flow-map, product-design pack), not this animation plan. If that work changes the slot model, the card-flight contract's target (slot identity/position) is re-reviewed here before implementation; the movement mechanics (optimistic commit, clone overlay, snap-to-end) are unaffected.
⚠ These are ephemeral previews, not production code. The demos show directional motion; the scrubbers show the phase timing data. All timings are design targets that pass through dbg.scaleT at implementation time. No state persists.
Directional demo — blueprint-morph
Tap the Devtool AFPS blueprint (or press Open). The chrome rectangle is the only continuously visible element; the blueprint sits at opacity 0 underneath while the panel owns the morph, and builder content staggers in only after the morph lands. Close runs the reverse: content vanishes near-instantly so the morph-back reads.
Directional demo — card-flight
Tap any card in the fan: it dims with an "in deck ♦" badge the same frame (optimistic commit), a clone flies above the sheet to its slot, the slot fills and pulses, the counter ticks. Collect all staggers launches 70 ms apart. Dismiss mid-flight demos snap-to-end: clones vanish, slots force-fill, the counter reconciles to the committed size. Re-tapping a collected card is a no-op. Scope note: this demo shows card movement only — the 1:1 card→slot mirror is a simplification, not the deck model (see Amendment 2 above; slots in the real design are suggestions with swap/overlay/custom-deck affordances).
blueprint-morph timeline
| Sequence | Phase | Window (ms) | What happens |
|---|---|---|---|
| Open | Tap commit | 0–20 | pushState; blueprint claims opacity 0; panel claims layoutId |
| Open | Layout spring morph | 20–470 | ~450 ms framer layout spring; chrome rectangle morphs table card → panel |
| Open | Phase flip | 470–490 | onLayoutAnimationComplete (one-shot ref) → builder-open |
| Open | Content stagger-in | 490–690 | Slot columns then shelf, ~200 ms total, 40 ms stagger |
| Close | Content fade-out | 0–10 | Near-instant (0.01 s) so the morph reads |
| Close | layoutId handback | 10–30 | AnimatePresence onExitComplete → blueprint reclaims id |
| Close | Morph-back (apex) | 30–380 | ~350 ms; completion frame is the flash-risk apex |
| Close | Table restored | 380–400 | Phase → table; focus returns to originating blueprint |
card-flight timeline
| Sequence | Phase | Window (ms) | What happens |
|---|---|---|---|
| Single tap | Tap commit | 0–16 | Optimistic commit (collectedCardIds + localStorage); fan card dims with "in deck ♦" badge, same frame |
| Single tap | Measure | 16–48 | Measure source rect; if slot off-screen, scrollIntoView({behavior:"instant"}) then re-measure next rAF |
| Single tap | Clone flight | 48–448 | Clone mounts in FlightLayer at source rect; imperative animate() x/y/scale, spring {stiffness: 260, damping: 26} (~400 ms) |
| Single tap | Land + pulse | 448–698 | .then(): clone removed, slot ghost→filled, declarative pulse scale [1, 1.08, 1] ~250 ms, counter ticks |
| Add-all | Staggered launches | 0–840 | Filtered queue, 70 ms stagger per flight (13-card worst case ≈ 0.84 s launch tail) |
| Add-all | Last clone in flight | 840–1240 | Concurrent flights overlap; each follows the single-tap spring |
| Add-all | Last land + batch-complete | 1240–1490 | Final pulse; batch-complete via decrement ref; single tap is a batch of 1 |
5. Lifecycle Ownership Map
Hierarchical composition; the existing PackFlowPhase machine is nested unchanged:
DeckTableShell (client; rendered by both app/page.tsx and app/deck/[slug]/page.tsx)
├── owns DeckFlowPhase: table → blueprint-morphing → builder-open → builder-dismissing → table
├── owns activeDeck + collectedCardIds (localStorage-backed, per slug)
├── one <LayoutGroup> wrapping table + builder
├── TableSurface (always mounted)
├── BuilderPanel (AnimatePresence; mounted while activeDeck != null)
│ └── BuilderPackFlow — PackFlowPhase owner extracted from PrototypeInner
│ (sealed → opening-apex → drawer-open → closing-* chain, verbatim)
│ └── owns flights Map<cardId, FlightRecord> (parallel records, NOT a scalar phase:
│ queued → measuring → in-flight → settling → done per record)
└── FlightLayer (fixed inset-0, pointer-events-none, z-[70]) — flight clones
window.history.pushState (+ popstate listener), because App Router route-change unmounts are not AnimatePresence-aware — a route-first intercepting-route close would unmount the builder before the blueprint can reclaim the layoutId. /deck/[slug] exists as a real route for hard loads/SEO and renders the same shell with initialDeckSlug.
pushState + usePathname/popstate behavior on Next 16.2.6 — flagged as the top pre-implementation risk.
Identity rules
- Fan cards keyed by
skill.id, never filtered/reordered on collect (preserves PackOpener's fan/collapse math). - Flight clones keyed
flight-${skill.id}, max one per card. - Slots keyed
${deckSlug}:${slotId}. - Exactly one mounted owner of
deck-blueprint-${slug}at a time — source stays mounted at opacity 0, the SealedPack pattern.
Commit timing
Optimistic — collectedCardIds + localStorage commit at tap time; animation is pure presentation; a presentation-level displayedCount ticks on flight-land and reconciles on any interrupt. No rollback paths exist.
Interruption rule
finishAllFlightsImmediately() (snap-to-end: stop controls, remove clones, force slots filled, reconcile counter) runs before any drawer close or builder dismiss. Back/dismiss with the drawer open uses a fast drawer teardown (BottomSheet's existing 0.01s fadeExit path, skip the collapse ritual) so leaving feels immediate.
Gate: lifecycle ownership & routing
Do you approve the hierarchical ownership map (DeckTableShell owns DeckFlowPhase; the existing PackFlowPhase machine nests unchanged) and the client-state-first routing decision (shallow pushState, with the Next 16.2.6 spike run before implementation)?
Gate: interruption semantics & commit timing
Do you approve snap-to-end interruption handling (finishAllFlightsImmediately() before any close/dismiss; fast drawer teardown on back) and the optimistic commit model (state + localStorage commit at tap time, no rollback paths)?
6. Interactive: State Machine Explorer
⚠ This is an ephemeral preview, not production code. Click a state node to highlight its legal transitions and guards. The DeckFlowPhase chain is the new shell machine; the FlightRecord chain is per-card (a Map of parallel records, not a scalar phase). No state persists.
| Machine | From | To | Trigger / guard |
|---|---|---|---|
| DeckFlowPhase | table | blueprint-morphing | Blueprint tap — taps ignored unless phase is table |
| DeckFlowPhase | blueprint-morphing | builder-open | onLayoutAnimationComplete (one-shot ref) |
| DeckFlowPhase | blueprint-morphing | builder-dismissing | Dismiss-during-morph — retargets the layout morph from its current projected position |
| DeckFlowPhase | builder-open | builder-dismissing | Back/dismiss (only legal here); runs finishAllFlightsImmediately() + fast drawer teardown first |
| DeckFlowPhase | builder-dismissing | table | Morph-back onLayoutAnimationComplete; focus restored to originating blueprint |
| DeckFlowPhase | (hard load) | builder-open | Direct load of /deck/[slug] — no origin, open morph skipped; close still morphs back |
| FlightRecord | queued | measuring | Dequeue (add-all stagger 70 ms) or immediate for single tap |
| FlightRecord | measuring | in-flight | Source rect measured (scrollIntoView + re-measure next rAF if slot off-screen); clone mounts in FlightLayer |
| FlightRecord | in-flight | settling | Imperative spring resolves (.then()); clone removed; slot ghost→filled |
| FlightRecord | settling | done | Pulse complete; displayedCount ticks |
| FlightRecord | any non-done | done | finishAllFlightsImmediately() — snap-to-end, counter reconciled |
7. Storyboards & Mechanism Decision
All timings pass through dbg.scaleT and are debug-speed scalable.
blueprint-morph open
- Tap →
pushState+ blueprint claims opacity 0 / panel claims layoutId. - Layout spring morph (~450 ms, framer layout spring).
onLayoutAnimationComplete(one-shot ref) →builder-open.- Content stagger-in (slot columns then shelf, ~200 ms, 40 ms stagger).
blueprint-morph close
- Content near-instant fade-out (0.01 s, so the morph reads).
- AnimatePresence
onExitComplete→ blueprint reclaims id. - Morph-back (~350 ms) →
onLayoutAnimationComplete→table, focus restored.
Morph-back completion frame is the flash-risk apex. It gets a dedicated gate step in the debug harness and a named manual slow-motion check in the proof gate.
card-flight
- Tap → optimistic commit + fan card dims (same frame).
- Measure source rect; if the target slot is off-screen,
scrollIntoView({behavior:"instant"})then re-measure next rAF. - Clone mounts in FlightLayer at source rect.
- Imperative
animate()x/y/scale to slot rect, spring{stiffness: 260, damping: 26}(~400 ms). .then(): clone removed, slot ghost→filled, declarative pulsescale [1, 1.08, 1]~250 ms, counter ticks.
Add-all: filtered queue, 70 ms stagger per flight (13-card worst case ≈ 0.84 s launch tail), batch-complete via decrement ref; single tap is a batch of 1.
PackOpener.tsx:212-213) is why the clone is driven imperatively with motion values, and why slot cards (which need declarative pulses) never carry a layoutId.
8. Implementation Guardrails (motion-framer subskill + baseline)
- Transform/opacity only; the morph animates the panel chrome rectangle, never the slot grid layout; no filter/shadow animation.
- BuilderPanel's layoutId container takes no declarative
animateon transforms; content motion lives on child wrappers. - AnimatePresence placement: BuilderPanel's presence is owned by DeckTableShell (the component that decides mounting), mode
sync; BottomSheet keeps its existing presence handling. - Reduced motion (
useReducedMotion(), read once in the shell): same state chain and callbacks fire in the same order (debug/test parity), durations degrade — blueprint-morph becomes a 120 ms opacity crossfade with layoutId omitted; card-flight mounts no clone, launch/land marks fire back-to-back, slot fills with a 120 ms fade, no pulse, add-all has no stagger. Retrofitting the existing pack flow is a named follow-up, out of scope. - Pointer events: FlightLayer
pointer-events-none; scrim/dismiss disabled duringblueprint-morphingandbuilder-dismissing; focus management per contract A.
Gate: scope / non-goals
The two new transitions ship with full reduced-motion behavior, but retrofitting prefers-reduced-motion support onto the existing pack flow (SealedPack tear, fan-out, collapse) is a named follow-up and out of scope for this plan. Confirm?
9. Debug-Harness Extension
New step arrays in steps.ts (+ nodes/lanes deck-shell, builder, flight-layer and runtime slices in animationMachine.ts):
| Step array | Steps |
|---|---|
DECK_OPEN_STEPS | blueprint-tap → url-push → builder-mount → blueprint-morph-in (gate) → builder-content-in |
DECK_CLOSE_STEPS | dismiss-trigger → flights-flushed (cond.) → drawer-fast-teardown (cond.) → builder-exit (gate) → blueprint-morph-out (gate, apex) → table-restored |
FLIGHT_STEPS | flight-tap → flight-measure → flight-launch (gate) → flight-land (gate) → flight-batch-complete |
New debug drivers: openDeck, dismissDeck, flyCard, flyAll, extended reset. All new transitions pass through dbg.scaleT.
10. Proof Gate
User direction during planning: add Playwright at implementation time.
Vitest sequence tests (existing pattern — mocked debug report() channel, mocked getBoundingClientRect)
- Morph open/close phase order.
- Flight optimistic-commit + interrupt reconciliation (close-mid-flight leaves counter == collected size).
- Re-tap no-op.
- Hard-load
/deck/[slug]entersbuilder-openwithout morph.
Playwright (devDependency added in the deck-builder implementation phase, local runs only — no GitHub Actions)
- Screenshots at each phase boundary.
- Video of morph open/close and a 3-card add-all.
- Assert no frame shows double-vision or a clipped clone.
- A
prefers-reduced-motionproject run asserting crossfade/instant-fill behavior.
Manual slow-motion review (at --debug-speed 0.25)
- Morph-back apex frame (flash risk).
- Clone z-order over scrim/sheet/slot strip.
- Perceived continuity of the chrome rectangle.
- Interruption feel (back-during-flight).
Acceptance: every "never" item in contracts A/B has a corresponding automated or named-manual check; onExitComplete-dependent cleanup verified to run after exit, not during.
Gate: proof tooling
Confirm adding Playwright as a devDependency during the deck-builder implementation phase (local runs only, no GitHub Actions), alongside the Vitest sequence tests and the named manual slow-motion checks?
11. Assumptions & Risk Register
| Risk / assumption | Status | Mitigation | What would change the plan |
|---|---|---|---|
Next 16 shallow-pushState spike — shallow window.history.pushState + usePathname/popstate behavior on Next 16.2.6 | Load-bearing, unproven | ~30 min spike before any implementation | If shallow pushState fights the App Router, the routing layer is redesigned (e.g. full client-canvas with route only on hard load) before any animation work starts |
| Morph legibility across extreme aspect-ratio change (small blueprint → full panel) | Provisional | Chrome-rectangle-only morph; content staggers in after landing | If the morph still reads poorly at slow motion, fall back to scale+fade with a shorter morph distance |
| Mobile slot-strip scroll-then-measure timing (scrollIntoView → re-measure next rAF) | Provisional | Manual device check in the proof gate | If measurement races persist, pin the slot strip during flights |
| Scroll drift during a ~400 ms flight (user scrolls while clone is mid-air) | Accepted limitation | Documented; clone targets the rect measured at launch | Only revisit if real-device review finds it disorienting |
12. Artifact Destination & Proposed File Changes
This run created (already committed with this page)
| Path | Action |
|---|---|
prompts/animation-design-planner/skill-prompt-20260610-135323-deck-builder-transitions.md | create — prompt history log |
research/skills-showcase/_working/preliminary-animation-design-planner-research.md | create — Stage 1 working packet (non-canonical) |
alignment/animation-design-planner-deck-builder-transitions.html | create — this review page |
alignment/index.html | update — add entry under Product Design & Spec |
Post-approval mutations (a later turn, only after final compiled YAML)
| Path | Action |
|---|---|
apps/skills-showcase/docs/animation-plan-deck-builder.md | create — canonical animation plan (this page's content, post-feedback) |
docs/history/archive/YYYY-MM-DD/HHMMSS/research/skills-showcase/_working/… | archive — working packet moved out of the active tree |
alignment/animation-design-planner-deck-builder-transitions.html | convert — review → confirmed (prior page archived first) |
No implementation files, no dependencies, and no changes to the unrelated in-flight skillpacks work in the tree.
Gate: artifact destination
Approve apps/skills-showcase/docs/animation-plan-deck-builder.md as the canonical destination for the approved animation plan (written only after approval, next to deck-builder-ux.md)?
Gate: proposed file changes
Approve the file-change scope and timing: this run's four review artifacts now, and the post-approval mutation set (archive the working packet, write the canonical plan, confirm this page) only after final compiled YAML?
13. Post-Approval Route
After approval, the natural next step is the deck-builder implementation handoff: the canonical plan plus deck-builder-ux.md become the spec inputs, with the Next 16.2.6 pushState spike as the first implementation task.
Gate: post-approval route
After the canonical plan is written, how should the work route?
14. Compile YAML
Use Compile Feedback YAML to send emphasis requests, concerns, or clarification needs before answering every gate — feedback alone is a revision request, not approval. Use Compile Answers for final approval once all 9 required questions are answered.