diff --git a/.planning/phases/02-ui-performance/02-12-PLAN.md b/.planning/phases/02-ui-performance/02-12-PLAN.md
new file mode 100644
index 00000000..1d11b3e1
--- /dev/null
+++ b/.planning/phases/02-ui-performance/02-12-PLAN.md
@@ -0,0 +1,265 @@
+---
+phase: 02-ui-performance
+plan: 12
+type: execute
+wave: 9
+depends_on: ["02-11"]
+files_modified:
+ - neode-ui/src/composables/useEntranceStagger.ts
+ - neode-ui/src/composables/__tests__/useEntranceStagger.test.ts
+ - neode-ui/src/views/Discover.vue
+ - neode-ui/src/views/Apps.vue
+ - neode-ui/src/views/Marketplace.vue
+ - neode-ui/src/views/web5/Web5.vue
+ - neode-ui/src/views/Home.vue
+ - neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
+ - .planning/phases/02-ui-performance/02-FINDINGS.md
+ - .planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json
+ - .planning/REQUIREMENTS.md
+autonomous: false
+gap_closure: true
+requirements: [PERF-02]
+must_haves:
+ truths:
+ - "Every `card-stagger`/`home-card-animate` entrance-animation site named in 02-11-SUMMARY.md's blast radius (Discover.vue, Apps.vue, Marketplace.vue, Home.vue, plus the Web5 sub-cards that consume Web5.vue's flag) is fixed by ONE shared mechanism, not seven ad-hoc patches"
+ - "First genuine mount of a session still plays the entrance animation exactly as before — no visual/timing/easing change to the animation itself, only to whether it replays on a later KeepAlive reactivation"
+ - "A KeepAlive deactivate/reactivate round-trip on a surviving instance never re-applies the entrance class — proven with a regression test that fails on the pre-fix code and passes after"
+ - "A navigate-away mid-animation, then a later return, never leaves a card half-animated or invisible — the class comes off on deactivate regardless of whether the animation had finished"
+ - "Home.vue's dual-purpose `animateCards` ref (gates both the welcome-overlay visibility AND the entrance-class trigger) is not itself touched — a separate ref carries the entrance-only concern so the overlay/EasyHome visibility contract this phase's own checkpoint history flagged as fragile stays provably unchanged"
+ - "keepAliveTabs.test.ts stays byte-for-byte unmodified and green — this plan touches animation-replay timing only, never the structural DOM shape or the dashboard's own tab slide/depth transitions"
+ - "Discover, Apps, Marketplace and Home are all re-measured on archi-dev-box with the frozen harness after the fix; none regresses past its 02-11 FINAL number"
+ artifacts:
+ - "neode-ui/src/composables/useEntranceStagger.ts — the single shared mechanism (an entrance-stagger flag that clears itself on KeepAlive deactivation)"
+ - ".planning/phases/02-ui-performance/02-FINDINGS.md — new subsection recording the fix, its regression tests, and the re-measured numbers"
+ - ".planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json — post-fix 5-run/surface re-measure against archi-dev-box"
+ key_links:
+ - "useEntranceStagger.ts -> Discover.vue/Apps.vue/Marketplace.vue/Web5.vue (arm-at-setup-time usage) and Home.vue (manual arm() from the welcome-typing watcher, decoupled from animateCards)"
+ - "02-PERF-CARD-STAGGER.json -> 02-PERF-FINAL.json (02-11) — same four surfaces, same harness, joined by surface `id`"
+---
+
+
+02-11 profiled Discover's revisit cost directly (not guessed) and found a SECOND, distinct cause beyond the three leaked pollers it fixed: the `card-stagger`/`home-card-animate` entrance-animation classes are baked into the DOM at first mount and never programmatically removed. Chromium restarts `animation-fill-mode:forwards` CSS animations on DOM reattachment — independent of Vue's own reactivity — so every KeepAlive detach/reactivate cycle replays the entrance animation on a revisit, even though the JS-level "once per session" flag governing each view is itself correct and untouched. 02-11 named this cause with full profiling evidence (a diagnostic showing Discover's card count transiently doubling 19->34->19 on every single revisit, and an `animationstart`/`animationend` event log spanning the whole revisit window) but deliberately left it unfixed — the blast radius (Discover.vue, Apps.vue, Marketplace.vue, Home.vue, and several Web5 sub-cards) exceeded that plan's scope and needed its own real-device verification budget.
+
+This plan closes that gap. It is a bug fix, not a design change: the entrance animation is correct and stays exactly as-is on a genuine first visit. What's wrong is that it replays on a revisit to an already-visited, KeepAlive-cached tab — which directly contradicts this phase's own approved acceptance criteria ("switching between main tabs renders the target view immediately from cached state ... no blank screens or long spinners on tabs already visited this session"). Stopping the replay restores the intended behavior; it does not redesign anything. Do not touch the animation's keyframes, durations, easing, or the final visual look of any card — only whether the browser is ever given a reason to run it twice.
+
+Fix every affected site with ONE shared mechanism (a composable), not seven independent patches — 02-11-SUMMARY.md explicitly flagged that "seven copies of this logic will drift" as the reason to scope this as a dedicated follow-up with its own verification budget rather than a quick multi-file patch.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@CLAUDE.md
+
+Read in full:
+- `.planning/phases/02-ui-performance/02-11-SUMMARY.md` — the evidence and the named blast radius (search "Discover's second cause").
+- `.planning/phases/02-ui-performance/02-FINDINGS.md`, the section starting `### Discover: a second, distinct cause found` — the exact mechanism, the diagnostic proving it (card-count doubling, animation event log), and why 02-11 didn't fix it.
+- `neode-ui/src/views/Discover.vue`, `neode-ui/src/views/Apps.vue`, `neode-ui/src/views/Marketplace.vue`, `neode-ui/src/views/web5/Web5.vue`, `neode-ui/src/views/Home.vue` — every current `*AnimationDone`/`animateCards` site.
+- `neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts` — the established test pattern (synthetic consumer components using the exact `onActivated`/`onDeactivated` idiom, plus at least one test against a real converted view using `vm.$.uid`/`findComponent(...).props(...)` for instance-identity assertions that sidestep CSS-selector ambiguity).
+
+Read only the relevant part of these (they are large):
+- `.planning/phases/02-ui-performance/02-02-SUMMARY.md` — search "HARD RULE" and "checkpoint" — the KeepAlive wrapper architecture, and why this phase's own Task 3 checkpoint history treats visual/animation regressions as the single highest-risk failure mode for exactly this class of change (broken margins, dead transitions, caught only by a human eyeballing the real preview).
+
+Do not re-read `02-PERF-BASELINE.json`/`02-PERF-AFTER.json`/`02-PERF-REMEASURE.json`/`02-PERF-FINAL.json` in full — extract the Discover/Apps/Marketplace/Home rows with `node -e`/`jq` if a specific number is needed.
+
+
+
+
+
+ Task 1: Build the shared entrance-stagger composable and prove its mechanism in isolation
+ neode-ui/src/composables/useEntranceStagger.ts, neode-ui/src/composables/__tests__/useEntranceStagger.test.ts
+
+Create `neode-ui/src/composables/useEntranceStagger.ts`. It must expose a reactive flag and an `arm()` function, and must clear the flag to `false` in `onDeactivated` — before any later reactivation can hand a stale class to the browser:
+
+```ts
+import { ref, onDeactivated } from 'vue'
+
+export function useEntranceStagger() {
+ const showStagger = ref(false)
+
+ // Chromium restarts `animation-fill-mode: forwards` CSS animations on DOM
+ // reattachment (a KeepAlive detach/reactivate cycle), independent of Vue's
+ // own reactivity — see 02-FINDINGS.md "Discover: a second, distinct cause".
+ // Clearing this flag the instant the owning instance deactivates removes
+ // the class before any later reattachment has anything left to restart.
+ // A navigate-away mid-animation is handled identically: the class comes
+ // off immediately, which resolves to the CSS's own already-designed
+ // "no animation, fully shown" end state (see e.g. Home.vue's
+ // `.home-card:not(.home-card-animate)` rule) — visible only off-screen,
+ // never as an on-screen flash, because it only ever fires on deactivate.
+ onDeactivated(() => {
+ showStagger.value = false
+ })
+
+ return {
+ showStagger,
+ arm: () => { showStagger.value = true },
+ }
+}
+```
+
+Two call idioms this composable must support (both are needed by Task 2, prove both here):
+1. **Arm synchronously in `