Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit adcd0f7ce8
1826 changed files with 403825 additions and 0 deletions
@@ -0,0 +1,442 @@
---
phase: 02-ui-performance
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- neode-ui/src/composables/useCachedResource.ts
- neode-ui/src/composables/__tests__/useCachedResource.test.ts
- neode-ui/src/views/dashboard/keepAliveRoutes.ts
- neode-ui/src/views/dashboard/DashboardRouterView.vue
- neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts
- neode-ui/src/views/dashboard/useRouteTransitions.ts
- neode-ui/src/views/Dashboard.vue
- neode-ui/src/components/RefreshIndicator.vue
- neode-ui/src/views/Marketplace.vue
autonomous: false
requirements: [PERF-02]
must_haves:
truths:
- "Switching away from the tracer main tab and back renders its content with no spinner and no blank frame, from the surviving component instance"
- "The tracer tab's component instance is reused across a tab round-trip — it mounts once per session, not once per visit"
- "Returning to the tracer tab within the TTL issues no new RPC for its cached resource"
- "Returning to the tracer tab after the TTL has lapsed issues exactly one background revalidation and keeps the previous content on screen while it runs (D-01)"
- "While that background revalidation is in flight a subtle refresh indicator is visible, driven by loadState === 'refreshing' (D-05)"
- "A failed background refresh leaves the last known content on screen and raises no toast (D-07)"
- "A secondary screen reached from a tab's main page is not instance-cached — it mounts fresh each visit (D-04)"
- "The number of cached view instances is capped, so visiting every main tab does not grow the instance cache without bound (D-03)"
- "Scroll position within a main tab is restored on return rather than reset to the top"
- statement: "The route transition animations that played before the KeepAlive restructure still play afterwards, with the same names for the same navigations"
verification: backstop
prohibitions:
- "MUST NOT present cached data as live — a money- or liveness-critical surface (wallet balance, incoming payment, mesh peer reachability, app install or health state) must never render from cache without a visible refresh signal and an in-flight revalidation"
- "MUST NOT persist wallet balances, transaction history, credentials, DIDs, seed or identity material, or peer identity payloads to sessionStorage"
- "MUST NOT achieve perceived speed by removing behavior or hiding state — no suppressing the refresh indicator, no dropping a fetch the surface needs, no disabling a feature to win the metric"
artifacts:
- path: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
provides: "The single source of truth for which routes are instance-cached, plus the instance cap"
exports: ["shouldKeepAlive", "KEEP_ALIVE_PATHS", "KEEP_ALIVE_MAX"]
- path: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
provides: "The extracted, testable KeepAlive host — the nested RouterView that actually remounts on tab switch"
- path: "neode-ui/src/components/RefreshIndicator.vue"
provides: "Subtle background-refresh indicator driven by a loadState prop (D-05)"
- path: "neode-ui/src/composables/__tests__/useCachedResource.test.ts"
provides: "Coverage for the onActivated revalidation and the preserved sticky-ready / keep-last-value semantics"
- path: "neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts"
provides: "Proof that an included route's instance survives a round-trip and an excluded route's does not"
key_links:
- from: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
to: "neode-ui/src/views/dashboard/keepAliveRoutes.ts"
via: "calls shouldKeepAlive(route) to decide which branch renders the view"
pattern: "shouldKeepAlive"
- from: "neode-ui/src/composables/useCachedResource.ts"
to: "vue onActivated"
via: "reactivation triggers refreshIfStale so a KeepAlive'd tab still background-refreshes"
pattern: "onActivated"
- from: "neode-ui/src/views/Marketplace.vue"
to: "neode-ui/src/composables/useCachedResource.ts"
via: "the tracer tab's catalog and status fetches move onto keyed cached resources"
pattern: "useCachedResource"
- from: "neode-ui/src/views/Dashboard.vue"
to: "neode-ui/src/views/dashboard/DashboardRouterView.vue"
via: "Dashboard renders the extracted host in place of its inline nested RouterView"
pattern: "DashboardRouterView"
---
<objective>
PHASE TRACER. Wire one main tab end to end through every layer this phase touches —
route classification, the KeepAlive host inside `Dashboard.vue`'s nested RouterView, the
`useCachedResource` reactivation gap, the tab's own data fetches, and the subtle refresh
indicator — and prove with a runnable test that the tab renders from cache on revisit
while revalidating in the background.
This is the phase's thin end-to-end slice, sequenced immediately after the D-10 profiling
gate (plan 02-01), which CONTEXT.md locks as a hard prerequisite: no production source may
change before the findings doc is committed. Every later plan in this phase expands
horizontally from the architecture proven here. It is production quality, not a prototype
— the only thing "thin" about it is that exactly one tab is converted.
**Tracer tab selection:** use the highest-ranked slow main tab from `02-FINDINGS.md`
`## Ranked Fix Order`. Default and expected pick: `marketplace`
(`neode-ui/src/views/Marketplace.vue`) — the app store the user reported as the worst
surface, moderate size, and it exercises all three layers (instance cache, data cache,
refresh indicator). If the ranking's top entry is `mesh`, take the next entry instead:
`Mesh.vue` is 2,651 lines with a live D3 force graph and a Leaflet map, which exceeds a
single task's context budget and is planned separately as 02-05. Record the pick and the
reason in the SUMMARY.
Purpose: PERF-02 — main-tab switches render immediately from cached state with background
refresh. Proving the whole path on one tab first means an architectural dead end costs one
commit instead of ten.
Output: a working, instance-cached, stale-while-revalidate main tab; the shared
KeepAlive host and route classifier every other tab will use; the hook fix that makes
background refresh actually fire on revisit; and the tests that pin all of it.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-ui-performance/02-CONTEXT.md
@.planning/phases/02-ui-performance/02-RESEARCH.md
@.planning/phases/02-ui-performance/02-PATTERNS.md
@.planning/phases/02-ui-performance/02-FINDINGS.md
@.planning/codebase/CONVENTIONS.md
@CLAUDE.md
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: One main tab survives a tab round-trip and revalidates on return</name>
<reversibility rating="costly">`Dashboard.vue`'s nested RouterView is the single mount point every dashboard view renders through, and `useCachedResource` already has eight consumers — undoing either shape later means touching every view again.</reversibility>
<files>neode-ui/src/composables/useCachedResource.ts, neode-ui/src/composables/__tests__/useCachedResource.test.ts, neode-ui/src/views/dashboard/keepAliveRoutes.ts, neode-ui/src/views/dashboard/DashboardRouterView.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts, neode-ui/src/views/dashboard/useRouteTransitions.ts, neode-ui/src/views/Dashboard.vue</files>
<read_first>
- `neode-ui/src/views/Dashboard.vue` — read the template around lines 87-118. The nested `<RouterView v-slot="{ Component, route }">` sits inside `<Transition>`, whose child is a `<div :key="route.path" class="view-wrapper">` that then branches into two wrapper shapes. This is the structure being restructured; read it before touching it.
- `neode-ui/src/composables/useCachedResource.ts` — all 107 lines. Note `refreshIfStale()` (line ~72), `stale()` (line ~71), the `getCurrentScope()` + `onScopeDispose` block (lines ~86-92), and the `if (opts.immediate ?? true) refreshIfStale()` call (line ~94) whose placement the new hook mirrors.
- `neode-ui/src/stores/resources.ts` — the backing store; `refresh()` already dedupes concurrent calls per key via its `inflight` map, which is why an extra reactivation-triggered call on first mount is harmless.
- `neode-ui/src/views/dashboard/useRouteTransitions.ts``TAB_ORDER` (lines 4-15) is the canonical main-tab path list; `getTransitionName()` must keep working unchanged; `isDetailRoute()` is deliberately NOT the classifier used here.
- `neode-ui/src/router/index.ts` — the dashboard child routes and their `name` values; confirm the tracer tab's path and that detail routes such as `apps/:id` and `marketplace/:id` are siblings under the same parent.
- `neode-ui/src/views/Marketplace.vue` — the tracer tab (unless the findings ranking says otherwise). Read `onMounted` at line ~377, `loadCommunityMarketplace()`, `loadBitcoinPruneStatus()` (fetches `/bitcoin-status`), and the `marketplaceAnimationDone` one-shot flag.
- `neode-ui/src/views/Cloud.vue` lines 505-530 and 945-970 — the in-repo reference for defining a cached resource and for the keep-last-value error handling to mirror.
- `neode-ui/src/views/__tests__/CloudPeersRefresh.test.ts` — the in-repo Vitest + `@vue/test-utils` + Pinia mounting pattern to follow for the new tests.
- `.planning/phases/02-ui-performance/02-FINDINGS.md` — the ranked fix order that selects the tracer tab, and the measured cause for that tab.
</read_first>
<action>
Write the failing tests first, then make them pass.
**A. Route classifier.** Create `neode-ui/src/views/dashboard/keepAliveRoutes.ts`
exporting `KEEP_ALIVE_MAX` (set to 6), `KEEP_ALIVE_PATHS` (a `ReadonlySet<string>`
seeded with ONLY the tracer tab's path — plan 02-04 widens it after the lifecycle
audit), and `shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string })`
returning true only for an exact path match. Exact-match, not prefix-match: a
prefix match would sweep in `/dashboard/marketplace/:id` and every other secondary
screen, which D-04 forbids from the instance cache. Do not derive this from
`isDetailRoute()` — that helper only recognises `/apps/` and `/marketplace/` details
and misses `cloud/:folderId`, `server/openwrt`, `web5/credentials`, `goals/:goalId`
and `app-session/:appId`. Export `TAB_ORDER` from `useRouteTransitions.ts` (it is
currently a module-private `const`) so plan 02-04 can widen `KEEP_ALIVE_PATHS` from
it without editing that file again.
Deliberately do not use `<KeepAlive :include>` name matching. Every one of the 44
routes is an async component (`component: () => import(...)`) and no view in this
codebase calls `defineOptions({ name })`, so `include` would depend on name
inference through the async wrapper — the failure mode RESEARCH.md flags as
assumption A1 (vuejs/core issue 11764). Route-path classification sidesteps it and
is also the fix for RESEARCH.md pitfall 7.
**B. Extract and restructure the KeepAlive host.** Create
`neode-ui/src/views/dashboard/DashboardRouterView.vue` holding the nested
`<RouterView v-slot="{ Component, route }">` currently inlined in `Dashboard.vue`,
taking `mobileTabPaddingTop: number | null` and `needsMobileBackButtonSpace: boolean`
as props (both are computed in `Dashboard.vue` today). Replace that inline block in
`Dashboard.vue` with `<DashboardRouterView :mobile-tab-padding-top="..."
:needs-mobile-back-button-space="..." />`.
Four structural invariants govern the new template, and the tests below exist to
pin them:
1. Nothing between the RouterView slot and `<KeepAlive>` may carry a binding that
changes identity per route. The current `<div :key="route.path">` sits exactly
there; if a `<KeepAlive>` is nested under it, that div is torn down on every
navigation and takes the entire instance cache with it, producing a change that
reviews clean and improves nothing. Hoist the wrapper out and drive its
appearance from route-derived computed values instead of from a changing key.
2. Composition order is `<Transition>` outside `<KeepAlive>` outside
`<component :is="Component">`.
3. The `:key="route.path"` binding belongs on `<component :is>` itself, never on an
ancestor of `<KeepAlive>`.
4. Both existing wrapper shapes must survive byte-for-byte in their visual result:
the chat/mesh branch (`h-full`, plus `dashboard-scroll-panel mobile-scroll-pad
mesh-dashboard-panel` for the mesh path, plus `overflow-y-auto` and the
`mobileTabPaddingTop + 16` padding when that prop is set, plus `mobile-safe-top`)
and the default branch (`absolute inset-0 px-4 pt-4 md:pt-8 md:px-8 overflow-y-auto
mobile-safe-top dashboard-scroll-panel`, plus `mobile-scroll-pad-back` or
`mobile-scroll-pad`, the `view-container flex-none` class applied to the rendered
component, and the trailing `shrink-0 h-6 md:h-12` spacer div).
Express the two shapes as computed helpers in the new component (for example
`isFullBleedRoute(route)`, `wrapperClass(route)`, `wrapperStyle(route)`) applied to a
single stable wrapper element, and render two sibling branches inside it: a
`<Transition><KeepAlive :max="KEEP_ALIVE_MAX"><component :is="Component"
:key="route.path" v-if="shouldKeepAlive(route)" /></KeepAlive></Transition>` branch
and a plain `<Transition><component :is="Component" :key="route.path"
v-else /></Transition>` branch for everything else. `getTransitionName(route)` keeps
driving both.
Because the default branch's wrapper is the scroll container and it is now stable
across routes, add explicit per-route scroll retention in the new component: keep a
`Map<string, number>` of `scrollTop` by route path, write the outgoing path's value
in a `watch` on `route.path` before the new view paints, and restore the incoming
path's value on `nextTick` after it does. Without this, a kept-alive tab would inherit
the previous tab's scroll offset, which is worse than today's reset-to-top.
**C. Close the reactivation gap in the hook.** In
`neode-ui/src/composables/useCachedResource.ts`, import `onActivated` from `vue` and
register `onActivated(() => refreshIfStale())` inside the existing
`if (getCurrentScope())` block, alongside `onScopeDispose`. Vue no-ops this hook
outside a `<KeepAlive>` boundary, so it is safe for all eight existing consumers.
Without it a kept-alive tab paints instantly forever and never revalidates, because
`onScopeDispose` does not fire on deactivate and the `window` focus listener does not
fire on an in-SPA tab switch. Add a short comment above it naming why reactivation is
a distinct trigger from mount and from focus.
**D. Register the tracer tab.** Seed `KEEP_ALIVE_PATHS` with exactly the tracer tab's
path and nothing else. Its data conversion lands in Task 2; this task proves the
instance survives and that the hook revalidates on reactivation, which is the
architectural question. Plan 02-04 widens the set after auditing every tab's
lifecycle — do not widen it here.
**E. The tests.** Create
`neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` mounting
`DashboardRouterView` with a `createRouter` on `createMemoryHistory` and two stub
route components that each increment a module-level mount counter in `onMounted` and
an activation counter in `onActivated`. Assert: navigating to the kept-alive path,
away, and back leaves the mount counter at 1 and the activation counter at 2; the
same round-trip on a detail path such as `/dashboard/marketplace/abc` leaves that
stub's mount counter at 2; and `shouldKeepAlive` returns false for a detail path
whose prefix matches an included path.
Create `neode-ui/src/composables/__tests__/useCachedResource.test.ts` mounting a
consumer component inside a real `<KeepAlive>` with a `vi.fn()` fetcher. Assert:
deactivate and reactivate inside the TTL calls the fetcher no additional times;
deactivate, advance fake timers past the TTL, reactivate calls it exactly once more;
the same hook used outside any `<KeepAlive>` mounts and fetches without throwing; a
rejected refresh leaves `entry.data` at its previous value with `entry.error` set;
and `loadState` moves ready to refreshing rather than back to loading.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check</automated>
</verify>
<acceptance_criteria>
- `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` exits 0
- `npm run test -- src/composables/__tests__/useCachedResource.test.ts` exits 0
- `npm run type-check` exits 0
- `npm run test` (full suite) exits 0 — the eight existing `useCachedResource` consumers are unregressed
- In the round-trip test the kept-alive stub records exactly 1 mount and 2 activations; the detail-route stub records 2 mounts
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` exports `shouldKeepAlive`, `KEEP_ALIVE_PATHS` and `KEEP_ALIVE_MAX`, and `shouldKeepAlive({ path: '/dashboard/marketplace/abc' })` returns false
- `neode-ui/src/composables/useCachedResource.ts` imports `onActivated` from `vue`: `grep -c "onActivated" neode-ui/src/composables/useCachedResource.ts` is at least 2
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` contains `KeepAlive` and `shouldKeepAlive`, and `neode-ui/src/views/Dashboard.vue` renders `DashboardRouterView`
- `KEEP_ALIVE_PATHS` contains exactly one entry — the tracer tab's path
- `npm run build` exits 0 and the built bundle carries the new code: after building, `grep -rl "shouldKeepAlive\|KeepAlive" web/dist/neode-ui/assets | head -1` prints a file (CLAUDE.md warns the frontend build can silently no-op)
</acceptance_criteria>
<done>The tracer tab renders from a surviving component instance on revisit, the hook revalidates on reactivation only when stale, a detail route still mounts fresh, and the full Vitest suite is green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab</name>
<files>neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts</files>
<read_first>
- `neode-ui/src/views/Marketplace.vue` — the tracer tab as left by Task 1; read its header/toolbar markup to find where a refresh affordance belongs, and its `marketplaceAnimationDone` one-shot flag at `onMounted` (line ~377)
- `neode-ui/src/components/` — list it and read two or three existing small components to match the house glass/dark styling, spacing and `<script setup lang="ts">` prop-typing conventions
- `neode-ui/src/stores/resources.ts` — the `ResourceLoadState` union (`idle | loading | ready | refreshing | error`) that the indicator's prop is typed against
- `.planning/codebase/CONVENTIONS.md` — component file structure, `defineProps<{}>()` typing, and the "types not enums" rule
- `.planning/phases/02-ui-performance/02-CONTEXT.md` — D-05 (subtle indicator, no stale-age badges), D-07 (silent keep-last-value), D-08 (persist policy)
</read_first>
<behavior>
- RefreshIndicator renders nothing when `state` is `ready` or `idle`
- RefreshIndicator renders its indicator element when `state` is `refreshing`
- RefreshIndicator renders nothing when `state` is `loading` — a first load is the view's own skeleton's job, not this component's
- The indicator element carries an accessible label and `aria-live="polite"` so a background refresh is announced without stealing focus
- When a background refresh on the tracer tab rejects, the previously rendered content is still in the DOM and no toast function is called
</behavior>
<action>
First put the tracer tab's data on the cache, following the `Cloud.vue` pattern: a
keyed resource per logical dataset, `computed` views over `entry.data` and
`entry.loadState`, and keep-last-value error handling that sets a banner ref rather
than raising a toast (D-07). For `Marketplace.vue` that means the shared app-catalog
fetch behind `loadCommunityMarketplace()` (key `app-catalog`, a long TTL of 300000 ms
— the catalog is near-static, per the D-06 discretion) and the `/bitcoin-status` fetch
behind `loadBitcoinPruneStatus()` (key `bitcoin.prune-status`, the 30000 ms default).
Put the catalog fetch behind a shared key rather than a Marketplace-private one so
`Discover.vue`, which calls the same loader, picks up the same cache entry without its
own conversion in plan 02-04. Decide `persist` explicitly per resource rather than
taking the default: `app-catalog` and `bitcoin.prune-status` are non-sensitive and
small, so both persist. Pass `dedup: true` on the underlying calls.
Then create `neode-ui/src/components/RefreshIndicator.vue`: a small presentational
component taking `state: ResourceLoadState` and an optional `label?: string`. It
renders a compact spinner or shimmer sized to sit inline in a view header — small
enough to read as ambient rather than as a blocking loader. Match the existing
design system: reuse the spinner treatment already present in the codebase (for
example the `chat-loading-spinner` rule in `Chat.vue`'s scoped styles) rather than
inventing a second spinner idiom, and use the same glass/white-alpha palette as its
neighbours. Exact placement and styling are Claude's discretion per CONTEXT.md, but
it must not shift layout when it appears and disappears — reserve its space or
position it absolutely.
Wire it into the tracer tab's header, bound to the tab's primary resource
`loadState`. Do not surface stale-age text or a "last updated" badge — D-05 rules
those out.
Then correct the tracer tab's per-visit behavior now that its instance survives.
`onMounted` fires exactly once for the lifetime of a kept-alive instance, so audit
every side effect in that view and place each one deliberately:
- Genuinely once-per-session setup stays in `onMounted`.
- Anything that should re-run on every tab entry moves to `onActivated`.
- Anything that should stop while the tab is off screen (intervals, subscriptions,
window listeners) gains a matching `onDeactivated` teardown, with `onActivated`
re-arming it.
For `Marketplace.vue` specifically: `marketplaceAnimationDone` is a one-shot intro
flag and stays where it is; the catalog and prune-status loads are now cache-gated
and revalidate through the hook's own `onActivated`, so they need no per-view hook.
Record in the SUMMARY every side effect you moved and every one you deliberately
left in `onMounted`, with the reason — plan 02-04 repeats this audit across the
remaining tabs and needs the precedent.
Confirm the error path matches D-07: a failed background refresh keeps the last
known content on screen and sets the view's existing error banner ref; it must not
call the toast composable. Errors surface on an explicit user-triggered refresh only.
Extend the existing test file with an indicator test: mount `RefreshIndicator` for
each `ResourceLoadState` value and assert the render-nothing / render-something
matrix in the behavior block above.
</action>
<verify>
<automated>cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts && npm run type-check && npm run test</automated>
</verify>
<acceptance_criteria>
- `neode-ui/src/components/RefreshIndicator.vue` exists and its props are typed with `defineProps<{ state: ResourceLoadState; label?: string }>()`
- Mounting `RefreshIndicator` with `state: 'refreshing'` renders a non-empty element; with `state: 'ready'`, `'idle'` and `'loading'` it renders nothing
- The rendered indicator element carries `aria-live="polite"` and a non-empty accessible label
- The tracer tab view imports `useCachedResource`, defines the `app-catalog` and `bitcoin.prune-status` keys with explicit `ttlMs` and `persist` values, and no longer calls those loaders from `onMounted` without a cached resource behind them
- The tracer tab view imports and renders `RefreshIndicator` bound to a resource `loadState`
- `npm run test` exits 0 and `npm run type-check` exits 0
- A rejected background refresh in the tracer tab test leaves the prior data rendered and invokes no toast
- The SUMMARY lists each side effect that moved to `onActivated`/`onDeactivated` and each one deliberately left in `onMounted`, with reasons
</acceptance_criteria>
<done>The tracer tab shows a subtle, non-layout-shifting refresh indicator during background revalidation, keeps its content on a failed refresh without a toast, and every one of its side effects is deliberately placed for the kept-alive lifecycle.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Confirm the tracer tab feels instant on the dev preview against archi-dev</name>
<what-built>
The KeepAlive host inside `Dashboard.vue`'s nested RouterView (extracted to
`DashboardRouterView.vue`), a route-path classifier capping the instance cache at 6,
the `onActivated` revalidation fix in `useCachedResource`, the tracer tab's data
moved onto keyed cached resources, and a subtle refresh indicator. Automated proof
already passing: component instance survives a tab round-trip, detail routes still
remount, no refetch inside the TTL, exactly one background refetch after it.
</what-built>
<how-to-verify>
1. From the repo root run `./scripts/dev-start.sh` and open the :8100 dev preview
pointed at archi-dev, per the phase's dev discipline (password `password123`).
2. Open the tracer tab (the app store / Marketplace unless the SUMMARY says
otherwise). Let it finish loading.
3. Switch to another main tab, then switch back. Expected: content appears
immediately — no spinner, no blank frame, no intro animation replay. Search text,
selected category and scroll position are as you left them.
4. Stay on another tab for longer than the TTL, then return. Expected: content is
still there instantly, and the small refresh indicator appears briefly in the
header while the data revalidates behind it. No full-screen loader, no layout jump.
5. Open a secondary screen from that tab (tap an app to reach its detail page), go
back, and open a different app. Expected: the detail screen behaves as before —
this plan deliberately does not instance-cache secondary screens.
6. Confirm the tab transition animation still plays when moving between main tabs,
and that it is the same animation as before this change.
7. Stop the backend (or pull the node's network) and return to the tracer tab after
the TTL. Expected: the previous content stays on screen, no error toast appears.
</how-to-verify>
<resume-signal>Type "approved", or describe what you saw: which step, what happened instead.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| node RPC / HTTP responses → browser cache | Untrusted-until-validated response payloads now live longer, in memory and in sessionStorage |
| browser tab session → sessionStorage | Cached payloads survive in-tab navigation and reload, readable by any script running on the origin |
| authenticated session → cached view instances | A kept-alive component instance holds rendered data across navigations and across a logout |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-02-01 | Information Disclosure | `useCachedResource` default `persist: true` writing to sessionStorage | high | mitigate | Task 1D requires an explicit per-resource `persist` decision rather than the default. The two tracer-tab resources (`app-catalog`, `bitcoin.prune-status`) are non-sensitive and persist; any resource carrying wallet figures, transaction history, credentials, DIDs or peer identity is memory-only (`persist: false`) |
| T-02-02 | Information Disclosure | Cached entries surviving a logout or identity switch | high | mitigate | Cache keys used here are node-global and non-identity-bearing. Purging the `resources` store and its `resource:` sessionStorage prefix on logout is specified and verified in plan 02-03 Task 3, which owns the identity-scoping work; this plan must not introduce an identity-bearing key before that lands |
| T-02-03 | Denial of Service | `<KeepAlive>` instance cache on low-power fleet nodes | medium | mitigate | `KEEP_ALIVE_MAX` of 6 caps resident instances with LRU eviction (D-03); Task 2 requires intervals and subscriptions to stop on `onDeactivated` so an off-screen tab costs no CPU; on-device memory is verified in plan 02-08 |
| T-02-09 | Tampering | Restructured `Dashboard.vue` render path | low | accept | The change is render-composition only — no auth guard, route guard or data-validation path is touched. `router/index.ts`'s existing navigation guards are unmodified |
| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope. `<KeepAlive>` is Vue core, `onActivated` is Vue core, and every other primitive already ships in this repo. If a task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing |
</threat_model>
<artifacts_this_phase_produces>
## Artifacts this phase produces
Symbols and paths created by this plan — new API, not drift from the existing codebase:
- `neode-ui/src/views/dashboard/keepAliveRoutes.ts``shouldKeepAlive()`, `KEEP_ALIVE_PATHS`, `KEEP_ALIVE_MAX`
- `neode-ui/src/views/dashboard/DashboardRouterView.vue` — props `mobileTabPaddingTop`, `needsMobileBackButtonSpace`; internal helpers `isFullBleedRoute()`, `wrapperClass()`, `wrapperStyle()`
- `neode-ui/src/components/RefreshIndicator.vue` — props `state: ResourceLoadState`, `label?: string`
- `neode-ui/src/composables/__tests__/useCachedResource.test.ts`
- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts`
- `TAB_ORDER` — promoted from module-private to an export of `neode-ui/src/views/dashboard/useRouteTransitions.ts`
- Cache keys introduced: `app-catalog`, `bitcoin.prune-status`
Created elsewhere in Phase 02: `neode-ui/e2e/perf/{surfaces,measure,surface-perf.spec}.ts`,
`.planning/phases/02-ui-performance/{02-FINDINGS.md,02-PERF-BASELINE.json,02-PERF-AFTER.json}`.
</artifacts_this_phase_produces>
<assumptions_and_flagged_items>
## Assumptions & Flagged Items
- **PERF-02 edge-probe row (spec-less fallback):** returned `unclassified` / `unresolved`. FLAGGED, not auto-backstopped and not dropped. Resolved in substance by this plan's `must_haves.truths`; one truth (transition-animation parity) is carried as a `verification: backstop` marker because it is a perceptual property the unit tests cannot confirm. The probe row itself stays unresolved and is surfaced here for human review.
- **FA-A (correction to `02-PATTERNS.md`):** PATTERNS.md line 43 advises leaving `:key="route.path"` on the outer wrapper `<div>`. That is unsafe once a `<KeepAlive>` is nested beneath it — a keyed ancestor is torn down on every navigation and destroys the instance cache, producing a change that reads correct and improves nothing. Task 1's round-trip mount-count assertion is the resolution.
- **FA-C (RESEARCH assumption A1):** whether Vue 3.5.24 fixes `KeepAlive` `include`/`exclude` name matching for async components is not settled. Resolved by design — this plan never uses `include`/`exclude`; classification is by route path.
- **FA-D (RESEARCH assumption A2):** the `max` cap value. Set to 6 against 10 entries in `TAB_ORDER`, so the long tail evicts while a normal working set stays resident. Not validated on hardware yet; plan 02-08 tunes it against on-device memory.
- **FA-F (correction to `02-CONTEXT.md` canonical_refs):** `App.vue`'s RouterView is not the remount point — it only ever swaps `OnboardingWrapper`, `Dashboard` and `NotFound`. The real point is the nested RouterView in `Dashboard.vue`, extracted here to `DashboardRouterView.vue`.
- **Open:** the scroll-retention `Map` is unbounded in principle (one number per visited path). Path count is bounded by the route table, so this is accepted rather than mitigated.
</assumptions_and_flagged_items>
<verification>
- `cd neode-ui && npm run test` exits 0
- `cd neode-ui && npm run type-check` exits 0
- `cd neode-ui && npm run build` exits 0 and the new symbols appear in `web/dist/neode-ui/assets`
- The human-verify checkpoint is approved against archi-dev on the :8100 preview
</verification>
<success_criteria>
- One main tab renders instantly from a surviving component instance on revisit, with no spinner and no blank frame
- A stale return fires exactly one background revalidation, visible as a subtle indicator, with content never leaving the screen
- A failed background refresh is silent and non-destructive
- Secondary screens are unaffected — they still mount fresh
- The instance cache is capped, and the shared classifier, host component and hook fix are in place for every later plan to build on
</success_criteria>
<output>
Create `.planning/phases/02-ui-performance/02-02-SUMMARY.md` when done. It MUST record:
the tracer tab actually chosen and why; the final `DashboardRouterView.vue` template
shape; every side effect moved to `onActivated`/`onDeactivated` versus left in
`onMounted`, with reasons; and the `persist` decision made for each new cache key.
Plans 02-04, 02-05, 02-06 and 02-07 read all four from this file.
</output>