34 KiB
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 |
|
|
false | true |
|
|
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.mdRead 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/animateCardssite.neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts— the established test pattern (synthetic consumer components using the exactonActivated/onDeactivatedidiom, plus at least one test against a real converted view usingvm.$.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.
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):
- 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. - 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 makesshowStagger.valuetrue before the first render (assert on the rendered class, not just the ref). - Deactivating an armed instance (toggle the
KeepAlivebranch) clearsshowStaggertofalse. - Reactivating that SAME instance afterward does not re-set
showStaggertotrue(no automatic re-arm — the caller decides if/when to arm again). - A genuinely different component instance (its own
arm()never yet called) starts withshowStaggerfalse and becomes true only when ITS OWNarm()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
animationendat 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, exportsshowStagger/arm, clears ononDeactivated, 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.
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):
- A synthetic test using the real
useEntranceStagger()composable directly (not Discover.vue) inside aKeepAlive-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 ownarm()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). - A real-view test: mount the real
DashboardRouterView+ realDiscover.vueat/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/settingsand back, and assert onwrapper.findComponent(AppGrid).props('showStagger')(orFeaturedApps, whichever is simpler to reach given the stubs chosen):trueimmediately after first mount,falseafter the round-trip, on the SAMEDiscoverinstance (pin instance identity viavm.$.uidtoo, 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.
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.
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):
- 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.
- 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.
- 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.tsor 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. - 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.
- 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> |
<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>
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 4–22, 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 86–99% 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 184–267ms 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.