Files
archy/.planning/phases/02-ui-performance/02-12-PLAN.md
T

34 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, gap_closure, requirements, must_haves
phase plan type wave depends_on files_modified autonomous gap_closure requirements must_haves
02-ui-performance 12 execute 9
02-11
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
false true
PERF-02
truths artifacts key_links
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
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
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`

CANCELLED

Cancelled 2026-07-31 by Dorian's explicit decision: "no changing animations allowed."

This plan is not being implemented, now or later. Entrance-animation behavior is Dorian's domain and is not to be changed, including the specific fix proposed below (clearing the card-stagger/home-card-animate class on KeepAlive deactivation so it doesn't replay on reactivation). No source files were changed under this plan — the one file that was written during investigation (neode-ui/src/composables/useEntranceStagger.ts, never imported/wired into anything) was deleted before this cancellation was recorded.

The underlying replay is understood and documented, not lost. The investigation that produced this plan, plus a follow-up read-only investigation performed after Dorian's cancellation decision arrived mid-execution, are preserved below and in the ## Investigation Findings (preserved — do not re-investigate) section at the end of this file, specifically so nobody re-does this analysis from scratch. 02-FINDINGS.md and REQUIREMENTS.md are unchanged by this cancellation — Discover's revisit cost stays exactly as measured in 02-11 (1389ms FINAL), recorded as a formally accepted deviation on Dorian's authority, not a defect awaiting a future fix.

Everything below this notice (objective, tasks, threat model, etc.) is the plan AS PROPOSED, kept verbatim for the record. It was never executed past Task 1 (which itself was rolled back — see the preserved findings section).


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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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:
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 <script setup>'s own body, guarded by the caller's existing module-scoped once-per-session flag (if (!xAnimationDone) { xAnimationDone = true; arm() }) — this must take effect before the component's first render, so the entrance class is present at first paint exactly as it is today.
  2. Arm later, from inside a watcher or event handler (Home.vue's welcome-typing sequence starts the animation only after login, not at mount) — arm() is safe to call any time before the component next deactivates.

Write neode-ui/src/composables/__tests__/useEntranceStagger.test.ts, mirroring keepAliveLifecycle.test.ts's own synthetic-consumer style (a small defineComponent mounted inside a real KeepAlive, vi.useFakeTimers() not needed here). Cover, against the REAL composable (not mocked):

  • Calling arm() during setup makes showStagger.value true before the first render (assert on the rendered class, not just the ref).
  • Deactivating an armed instance (toggle the KeepAlive branch) clears showStagger to false.
  • Reactivating that SAME instance afterward does not re-set showStagger to true (no automatic re-arm — the caller decides if/when to arm again).
  • A genuinely different component instance (its own arm() never yet called) starts with showStagger false and becomes true only when ITS OWN arm() runs — proving the flag is per-instance, not shared global state.
  • A deactivate that happens mid-"animation" (i.e., before any natural completion signal) still clears the flag — the composable has no dependency on animationend at all, so this is inherent, but assert it explicitly as a named regression case. cd neode-ui && npx vitest run src/composables/tests/useEntranceStagger.test.ts 2>&1 | tail -25 useEntranceStagger.ts exists, exports showStagger/arm, clears on onDeactivated, and its own test file proves the full contract (arms before first render, clears on deactivate, does not self-reapply on reactivate, is per-instance) against the real composable.
Task 2: Wire the composable into every affected view, without touching the animation itself or Home's overlay logic 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 First mount of Discover/Apps/Marketplace/Web5/Home plays its entrance animation exactly as today (byte-identical visual result — same classes, same CSS, same timing). A KeepAlive deactivate/reactivate round-trip on any of these five views never re-applies the entrance class to the surviving instance. A genuinely fresh component instance (a new mount, e.g. after LRU eviction, or a synthetic test double standing in for one) still gets the class applied on ITS first mount. **Discover.vue, Apps.vue, Marketplace.vue, Web5.vue** — identical pattern in each (only the module-scoped flag's name differs: `discoverAnimationDone`/`appsAnimationDone`/`marketplaceAnimationDone`/`web5AnimationDone`). Replace:
const showStagger = !xAnimationDone

with:

const { showStagger, arm: armXStagger } = useEntranceStagger()
if (!xAnimationDone) {
  xAnimationDone = true
  armXStagger()
}

and delete the now-redundant xAnimationDone = true line from that view's existing onMounted block (Apps.vue's and Web5.vue's onMounted blocks do other unrelated work too — e.g. armConnectionGuard(), the DID lookup, armWeb5Live() — leave everything else in those blocks untouched). showStagger stays a plain identifier referenced directly in the template (:show-stagger="showStagger" / :showStagger="showStagger" / :stagger="showStagger") — Vue's <script setup> compiler auto-unwraps a top-level ref referenced by name, so no template edits are needed in these four files, and no prop-type changes are needed in any child component (AppGrid.vue, FeaturedApps.vue, AppCard.vue, MarketplaceAppCard.vue, Web5Wallet.vue, Web5Identities.vue, Web5NodeVisibility.vue, Web5NostrRelays.vue, Web5QuickActions.vue, Web5SharedContent.vue, Web5Domains.vue) — they already declare showStagger/stagger as a reactive boolean prop and will re-render (removing their own card-stagger class) the instant the parent's ref flips to false. Confirm this by reading each child's prop declaration before assuming it — do not add a change to a file that doesn't need one.

Home.vue — do NOT reuse animateCards for this composable; it has a second, load-bearing duty (gating showWelcomeBlock && !animateCards visibility on several elements, and the :animate/:show props passed to EasyHome) that must stay completely untouched. Add a second, independent flag:

const { showStagger: cardEntranceStagger, arm: armCardEntrance } = useEntranceStagger()

In the watch(() => loginTransition.startWelcomeTyping, ...) handler, alongside the existing animateCards.value = true, add armCardEntrance(). Then retarget ONLY the five :class="{ 'home-card-animate': animateCards, ... }" template bindings (the ones that also gate home-card-animate) to read cardEntranceStagger instead of animateCards for that specific class key — leave every other use of animateCards in the template (the opacity-0 pointer-events-none overlay bindings, EasyHome's :animate/:show props) exactly as they are, unchanged, same identifier. Where a single :class binding currently has both 'home-card-animate': animateCards and 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards in the same object (line ~247), only the home-card-animate key's value changes; the opacity-0 pointer-events-none key's condition keeps reading animateCards unchanged.

Regression tests — add a new describe('keepAliveLifecycle: 02-12 gap closure — entrance-stagger class no longer replays on KeepAlive reactivation', ...) block to keepAliveLifecycle.test.ts, following its existing dual-coverage convention (a synthetic mechanism test plus a real-view test):

  1. A synthetic test using the real useEntranceStagger() composable directly (not Discover.vue) inside a KeepAlive-wrapped host, mirroring the file's existing "one-shot intro flag" test shape: mount, arm() on setup, assert the rendered class is present; deactivate + reactivate the SAME instance, assert the class stays absent; then mount a SEPARATE fresh instance (its own arm() call) and assert the class IS present on that new instance — the three explicit assertions this gap closure must prove (first mount applies; surviving-instance round-trip does not reapply; a genuinely fresh mount does).
  2. A real-view test: mount the real DashboardRouterView + real Discover.vue at /dashboard/discover (stub or mock whatever Discover.vue's setup needs to complete without a real network — mirror how the file already stubs Server.vue's dependencies), do the away-hop to a synthetic /dashboard/settings and back, and assert on wrapper.findComponent(AppGrid).props('showStagger') (or FeaturedApps, whichever is simpler to reach given the stubs chosen): true immediately after first mount, false after the round-trip, on the SAME Discover instance (pin instance identity via vm.$.uid too, exactly like the file's existing Server/Web5 tests, so a false pass from an accidental remount is ruled out).

Run the full suite, type-check, and build; confirm keepAliveTabs.test.ts is byte-for-byte unmodified (git diff --stat) and still green; grep the built bundle for a string unique to useEntranceStagger.ts (e.g. a distinctive local variable name survives minification poorly — instead grep for the new composable's file being present in the build's chunk manifest, or grep the AppGrid/FeaturedApps/Web5 chunk for a substring only introduced by this change) to rule out the silent-no-op-build hazard CLAUDE.md warns about. cd neode-ui && npm test 2>&1 | tail -20 cd neode-ui && npm run type-check cd neode-ui && npm run build 2>&1 | tail -10 git diff --stat -- neode-ui/src/views/dashboard/tests/keepAliveTabs.test.ts (must print nothing) Discover.vue, Apps.vue, Marketplace.vue, Web5.vue and Home.vue all use the shared composable; Home's animateCards is provably untouched (a decoupled second ref carries the entrance-only concern); the new keepAliveLifecycle.test.ts describe block proves first-mount/no-replay-on-reactivate/fresh-mount-does-replay against both the composable directly and a real Discover.vue; full suite/type-check/build green; keepAliveTabs.test.ts unmodified.

Task 3: Deploy frontend-only to archi-dev-box and re-measure Discover, Apps, Marketplace and Home with the frozen harness .planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json, .planning/phases/02-ui-performance/02-FINDINGS.md, .planning/REQUIREMENTS.md No `cargo`/`rustc` process is active on this shared box (`pgrep -af 'cargo|rustc'`) — this is a frontend-only deploy and re-measure, but a concurrent build still saturates CPU and would corrupt the timing numbers. Re-check immediately before the deploy and again immediately before the harness run. Confirm `git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts` prints nothing — the frozen harness stays untouched by this plan.

Deploy frontend-only from THIS checkout (the main checkout — never a worktree; this box is archi-dev-box itself over loopback, and deploy-to-target.sh already refuses a worktree-sourced deploy via its own guard, which must not be modified or bypassed):

ARCHIPELAGO_TARGET=archipelago@archi-dev-box scripts/deploy-to-target.sh --frontend-only

Confirm the post-deploy health check passes and the deploy history records dirty=false at Task 2's last commit hash.

Re-run the frozen harness exactly as prior gap-closure plans did:

cd neode-ui && ARCHY_BASE_URL=http://archi-dev-box ARCHY_PERF_RUNS=5 \
  ARCHY_PERF_OUT=../.planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json \
  npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line

(ARCHY_PASSWORD must be exported.) Confirm the frozen-harness git diff --stat gate above is STILL empty after the run.

Extract Discover/Apps/Marketplace/Home's rows from 02-PERF-CARD-STAGGER.json and compare against their 02-11 02-PERF-FINAL.json numbers (Discover 1389ms; Apps/Marketplace/Home were not regressed by 02-11, record their FINAL numbers as the no-regression bar). Report min/median/max, not a bare median.

Write a ### Discover second-cause fix: re-measured (gap closure, 02-12) subsection into 02-FINDINGS.md: the fix (one sentence), the before number (1389ms, 02-11 FINAL) and the after number, and the same for Apps/Marketplace/Home (their FINAL numbers as the floor they must not regress below). State plainly whether Discover now meets the phase's <300ms bar, and if not, whether the residual is a further defect or a separately-named, already-documented cost (e.g. the per-mount useCachedResource setup cost 02-11 already attributed elsewhere).

Update .planning/REQUIREMENTS.md's PERF-02 row to reflect this plan's outcome (the second Discover cause is now fixed and re-measured, not merely named).

Commit this task's docs+artifact changes (path-scoped git add, Co-Authored-By trailer) but do NOT push yet — Task 4's checkpoint must be approved first, per this plan's own autonomous: false gate. test -f .planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json && node -e "const r=require('./.planning/phases/02-ui-performance/02-PERF-CARD-STAGGER.json'); console.log('baseUrl:', r.baseUrl, 'runs:', r.runs)" git diff --stat 3ee20430 -- neode-ui/e2e/perf/surfaces.ts neode-ui/e2e/perf/measure.ts neode-ui/e2e/perf/surface-perf.spec.ts (must print nothing) grep -n "Discover second-cause fix" .planning/phases/02-ui-performance/02-FINDINGS.md 02-PERF-CARD-STAGGER.json exists (5 runs/surface, archi-dev-box, frozen harness). 02-FINDINGS.md records Discover/Apps/Marketplace/Home's before/after numbers with an explicit verdict for Discover against the <300ms bar. REQUIREMENTS.md's PERF-02 row reflects the fix. Committed, not yet pushed.

Task 4: Human verification on archi-dev-box — animation behavior across every touched surface The `card-stagger`/`home-card-animate` entrance-animation replay-on-KeepAlive-reactivation bug (02-11's evidenced-but-unfixed second cause on Discover) is fixed with one shared composable (`useEntranceStagger.ts`) applied to Discover.vue, Apps.vue, Marketplace.vue, Web5.vue, and Home.vue (via a decoupled second ref that leaves `animateCards`'s overlay/EasyHome duties untouched). Deployed frontend-only to archi-dev-box and re-measured with the frozen harness.

Before/after (median revisit ms, archi-dev-box, frozen harness, 5 runs):

  • Discover: 1389ms (02-11 FINAL) -> [fill in this task's measured number before presenting this checkpoint]
  • Apps / Marketplace / Home: 02-11 FINAL numbers (no-regression floor) -> this task's measured numbers On archi-dev-box (http://archi-dev-box), in a fresh browser session (private/incognito, or a cleared session, so "first visit this session" is genuine):
  1. First visit to Discover, Apps, Marketplace, and Home: confirm each still plays its entrance animation exactly as before — same stagger timing, same fade/fly-in look, nothing removed or shortened.
  2. Switch away from each tab and back (e.g. Discover -> Settings -> Discover; Home -> Discover -> Home): confirm the content is simply there on return — no re-stagger, no re-fade, no visible flicker or flash.
  3. The dashboard's own tab slide/depth transitions still play, and page margins are unchanged on every tab — these are the two things a previous change in this same phase (02-02) broke on its first attempt, and this plan does not touch keepAliveTabs.test.ts or the KeepAlive host itself, but a human check on the real preview is the only thing that can confirm a CSS-animation-adjacent change didn't have a side effect.
  4. Navigate away from Discover/Apps/Marketplace/Home mid-animation (click away within ~200ms of arriving) and then return: confirm nothing is left half-animated, invisible, or stuck at a partial opacity — cards should simply be fully visible on return, matching their finished state.
  5. Home specifically: log in fresh (or trigger the welcome-typing sequence if reachable) and confirm the welcome banner/typing effect and the home-card reveal still behave exactly as before, and that switching away from Home and back after the typing sequence has completed does not hide or fade the home cards again. Type "verified" (or describe anything that looks wrong) to approve. On approval: the executor pushes Task 2/3's commits (git push gitea-ai main), then writes 02-12-SUMMARY.md and updates STATE.md/ROADMAP.md/REQUIREMENTS.md's tracking exactly as any other completed plan. No SUMMARY.md is written before this checkpoint is approved.

<threat_model>

Trust Boundaries

Boundary Description
fixed build -> /opt/archipelago/web-ui on a real node Task 3's frontend-only deploy to archi-dev-box
operator workstation -> archi-dev-box UI login ARCHY_PASSWORD crosses this boundary at runtime to drive the harness re-measure

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-02-12-01 Information Disclosure ARCHY_PASSWORD high mitigate Environment variable only, never inlined into a committed command, never echoed into 02-FINDINGS.md, the plan, or the SUMMARY.
T-02-12-02 Tampering keepAliveTabs.test.ts (visual/structural contract) high mitigate Task 2 explicitly forbids editing this file; git diff --stat on this specific file is a hard verify gate in Task 2.
T-02-12-03 Tampering frozen harness (surfaces.ts/measure.ts/surface-perf.spec.ts) medium mitigate git diff --stat gate before and after Task 3's harness run, identical to every prior gap-closure plan this phase.
T-02-12-04 Tampering scripts/deploy-to-target.sh worktree-safety guard critical mitigate This plan runs from the main checkout only (per its own hard constraints); the guard itself is never modified or bypassed.
T-02-12-05 Denial of Service (regression) Home.vue's showWelcomeBlock/animateCards-gated overlay visibility high mitigate A separate, decoupled ref (cardEntranceStagger) carries the entrance-only concern; animateCards itself is never reassigned or read differently by this plan — verified by git diff review of Home.vue showing only additive lines plus five retargeted :class keys, not a rename or removal of any animateCards read site.
T-02-12-SC Tampering npm/pip/cargo installs high accept No new package-manager dependency is needed for a Vue composable or its tests. If one becomes necessary, halt and route through the Package Legitimacy Gate.
</threat_model>
- Every `card-stagger`/`home-card-animate` site named in 02-11-SUMMARY.md's blast radius is fixed by the one shared composable, not per-file patches. - A regression test (both the synthetic composable-level test and a real-Discover.vue test) fails on the pre-fix code and passes after, proving: first mount applies the class; a surviving instance's deactivate/reactivate round-trip does not reapply it; a genuinely fresh instance does. - Full vitest suite, type-check, and build are green; `keepAliveTabs.test.ts` is byte-for-byte unmodified and still passing. - Discover, Apps, Marketplace, and Home are re-measured on archi-dev-box with the frozen harness (git diff --stat confirms it untouched); none regresses below its 02-11 FINAL number. - A human confirms, on the real archi-dev-box preview, that first-visit animations are unchanged, revisits show no replay, the dashboard's own tab transitions and page margins are unaffected, and a mid-animation navigate-away/return leaves nothing half-animated.

<success_criteria>

  • Discover's second, previously-evidenced-but-unfixed cause (CSS entrance-animation replay on KeepAlive reactivation) is fixed and re-measured, not merely named.
  • Apps.vue, Marketplace.vue, Home.vue, and the Web5 sub-cards carrying the identical defect are fixed by the same mechanism, in the same plan, closing 02-11's full named blast radius rather than leaving a partial fix.
  • No visual or animation change to any surface's genuine first-visit behavior; no regression to keepAliveTabs.test.ts's structural contract or the dashboard's own tab transitions; no regression to Home's welcome-overlay visibility logic.
  • A human has verified the fix on real archi-dev-box hardware before this plan is considered complete. </success_criteria>
Create `.planning/phases/02-ui-performance/02-12-SUMMARY.md` ONLY after Task 4's checkpoint is approved — per-file before/after description of the fix, the regression tests and their pass/fail-before proof, the re-measured numbers for all four surfaces, confirmation of the human verification outcome, and confirmation the visual/animation and Home-overlay constraints held throughout.

Investigation Findings (preserved — do not re-investigate)

Recorded 2026-07-31, after Dorian's cancellation decision arrived mid-execution (Task 1's composable had been drafted, uncommitted, and was deleted; no other task ran). Kept here so a future session doesn't have to re-derive any of this from scratch, even though the fix itself is not to be implemented.

1. Exact blast radius (verified by direct grep of neode-ui/src, not just trusting 02-11's own list — confirmed accurate, nothing to correct):

Exactly 5 files own a flag: Discover.vue (discoverAnimationDone / showStagger), Apps.vue (appsAnimationDone), Marketplace.vue (marketplaceAnimationDone), web5/Web5.vue (web5AnimationDone), Home.vue (animateCards, a ref that only ever flips true and never back — same structural defect, different variable shape). Nine more files consume the flag as a plain boolean prop with no logic of their own: discover/AppGrid.vue, discover/FeaturedApps.vue, apps/AppCard.vue, marketplace/MarketplaceAppCard.vue, and the Web5 sub-cards Web5Wallet.vue, Web5Identities.vue, Web5NodeVisibility.vue, Web5NostrRelays.vue, Web5QuickActions.vue, Web5SharedContent.vue, Web5Domains.vue. Checked for sibling entrance-animation classes with the same defect (.animate-fade-up/.animate-fade-in/ .animate-fade-out in style.css): none apply to any KeepAlive'd tab — they're used only by RootRedirect.vue/OnboardingIntro.vue, neither of which is ever cached, so neither is affected.

Nuance not in 02-11: Discover has no sidebar entry — the perf harness's own navSteps (and the real user path) reach it via [click "Apps" in sidebar, click "App Store" tab inside Apps]. measure.ts's revisit timer starts at the first click (into Apps) and stops when Discover's content is visible, so the measured 1389ms "Discover" number already includes Apps.vue's own reactivation (and Apps' own card-stagger replay, if its installed-app count is large enough to matter) — a fix confined to Discover.vue alone would not fully address the measured number; Apps.vue would need to be fixed too, which the (now-cancelled) plan already accounted for.

2. What a user sees today on a revisit:

A full restart of the entrance cascade, not a partial or subtle effect — clearly perceptible, not sub-perceptual jank. Each card is opacity:0 at rest with animation: card-stagger-in 0.4s ... forwards; animation-delay: calc(var(--stagger-index) * 50ms). Discover's default view (category "all", no search) computes --stagger-index as the card's own v-for index + a 4-card offset; 02-11's diagnostic recorded 19 settled cards, so indices run roughly 422, giving a last-card delay of ~1.1s + its own 0.4s animation — the full cascade takes ~1.5s from first card to last, matching the diagnostic's own directly-observed animationstart/animationend event log spanning +241ms to +1716ms. This replays in full on every single revisit of the App Store tab.

3. Whether the animation is the whole of Discover's residual cost, or only part:

Best available evidence says it's the dominant cost specifically for Discover, though this was never isolated with a controlled A/B measurement (that would have been Task 3's job). Supporting evidence: 02-11's own diagnostic recorded first-paint at 901ms against a content-visible wall-clock of 1095ms in that run — almost the entire visible window was animation-driven paint, not RPC or compute (a CPU profile separately showed 8699% idle/program time on this and every other regressed surface, ruling out heavy JS computation). Calibration point: Apps.vue's own revisit already lands at 184267ms with the identical code pattern (presumably a much smaller installed-app count, so its own cascade is short even though it still technically replays) — by analogy, removing the replay was projected to bring Discover's revisit down substantially, plausibly into the same low-hundreds-of-ms range, though the exact number was never measured (Task 3 never ran). One more calibration point: pre-KeepAlive, every visit to Discover was a full remount, so the entrance animation legitimately played on every visit back then too — the ~1083ms pre-phase-2 baseline itself likely already contained this same ~1.5s cascade, which is plausibly why baseline was already slow. What KeepAlive changed was the intent (revisits should skip re-render entirely) without this animation-gating code ever being updated for that new contract.

Disposition: Accepted deviation, on Dorian's explicit authority (entrance-animation behavior is his domain). Discover's revisit cost stays at its 02-11 FINAL measurement (1389ms) indefinitely, or until Dorian himself decides otherwise. Not a defect awaiting a future fix.