`s depending on `route.path` (a chat/mesh branch with
+different classes, and a default branch with scroll-container classes). Both branches wrap
+`
` — the KeepAlive needs to live inside BOTH branches (or the
+branching needs to move to apply classes to a single shared wrapper so one KeepAlive
+covers both cases). This is real template surgery, not a one-line wrap.
+
+### Pattern 2: `useCachedResource` with `onActivated` revalidation (extension needed)
+**What:** Add reactivation-triggered staleness checks so KeepAlive'd tabs actually
+background-refresh on revisit, not just on window focus.
+**When to use:** Inside `useCachedResource` itself, so every consuming view benefits
+without per-view changes.
+**Example:**
+```typescript
+// Source: neode-ui/src/composables/useCachedResource.ts (existing) + Vue's onActivated
+// (vuejs.org/api/composition-api-lifecycle.html#onactivated) — safe no-op outside
+//
, so this is safe to add unconditionally for every consumer.
+import { onActivated } from 'vue'
+// ... inside useCachedResource, alongside the existing onScopeDispose block:
+if (getCurrentScope()) {
+ onActivated(() => refreshIfStale())
+}
+```
+This mirrors the existing `if (opts.immediate ?? true) refreshIfStale()` call at setup
+time — `onActivated` also fires on first mount (per Vue's lifecycle docs), so the two
+calls are redundant-but-harmless on first mount (the store's `inflight` map dedupes them)
+and the ONLY source of a stale-check on every subsequent reactivation.
+
+### Pattern 3: Parallelize a serial `onMounted` waterfall
+**What:** Replace sequential `await` chains with `Promise.all`/`Promise.allSettled`.
+**When to use:** Any `onMounted` (or other lifecycle hook) that awaits independent RPC
+calls one after another with no data dependency between them.
+**Example — the actual bug found in this codebase, `ContainerAppDetails.vue:169-172`:**
+```typescript
+// BEFORE (serial waterfall — 3 sequential round-trips):
+onMounted(async () => {
+ await loadContainer()
+ await loadLogs()
+ await loadHealthStatus()
+})
+
+// AFTER (parallel — same 3 calls, one round-trip's worth of latency):
+onMounted(async () => {
+ await Promise.allSettled([loadContainer(), loadLogs(), loadHealthStatus()])
+})
+```
+`loadContainer`/`loadLogs`/`loadHealthStatus` each set their own `loading` ref
+independently and don't read each other's results, so this is a safe parallelization —
+confirm this per-screen during profiling (D-02), don't blanket-apply.
+
+### Anti-Patterns to Avoid
+- **Wrapping `App.vue`'s outer `` with `` and calling it done:**
+ this RouterView only ever swaps `OnboardingWrapper.vue` ↔ `Dashboard.vue` ↔ 404 — it
+ doesn't remount on tab switch. The real fix must land in `Dashboard.vue`'s nested
+ RouterView. Fixing the wrong RouterView would look correct in code review but produce
+ zero observed improvement, since `Dashboard.vue` itself was never the thing remounting.
+- **Blanket `` with no `include`/`max`:** caches every route ever visited
+ forever — unbounded memory on low-power fleet nodes, and defeats D-04 (secondary
+ screens must NOT be instance-cached).
+- **Hand-rolling a second cache layer per view** (a local `ref` + manual
+ `sessionStorage.getItem`) instead of extending `useCachedResource` — this is exactly the
+ duplication the canonical_refs explicitly warn against.
+- **Treating `onMounted` as "runs once per view visit" once KeepAlive is added:** it does
+ not — `onMounted` only fires on first mount. Any per-visit logic (scroll restore,
+ one-shot animation flags like `Apps.vue`'s `appsAnimationDone`, `Server.vue`'s
+ `connectionTimer` setup) that assumed "onMounted = every view entry" must move to
+ `onActivated`/`onDeactivated` once that view is KeepAlive'd, or it silently stops running
+ on revisit.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Stale-while-revalidate data cache | A new per-view `ref` + manual `sessionStorage` read/write | `useCachedResource` (extend with `onActivated`, Pattern 2 above) | Already handles sticky-ready, keep-last-value-on-error, focus revalidation, abort-on-unmount, sessionStorage snapshotting — reinventing it per-view is exactly the "8 views already use it, main tabs just don't yet" gap this phase closes |
+| Component-instance persistence across route changes | A custom `v-show`-based always-mounted tab strip, or manual component-instance caching via a `Map` | Vue's built-in `` | Native primitive with `max`/LRU eviction, `include`/`exclude` name matching, and `activated`/`deactivated` hooks purpose-built for this |
+| In-flight request dedup | A custom promise-cache keyed by method+params | `rpc-client.ts`'s `dedup: true` option | Already implemented and used elsewhere (`Cloud.vue`, `Server.vue` pass `dedup: true` on several calls already) |
+| Parallel-fetch orchestration | A custom fetch queue/batcher | `Promise.all`/`Promise.allSettled` | Native JS; D-13 explicitly scopes fixes to this, reserving new endpoints for 3+ dependent calls only |
+| Performance measurement | Custom timing/telemetry instrumentation shipped to a backend | Browser DevTools Performance panel + `performance.mark`/`measure` (local, not shipped anywhere) | PERF-01 needs a committed findings doc, not a telemetry pipeline; out of scope per D-12 (no orchestrator/backend refactors) |
+
+**Key insight:** This phase has zero legitimate reasons to add a new library. The
+project already built (and 8 views already prove out) the exact SWR primitive this phase
+needs; the only real gaps are (1) nobody has wired `` into the actual remount
+point yet, and (2) the SWR hook itself needs one small extension (`onActivated`) to work
+correctly once component instances stop being destroyed on tab switch.
+
+## Common Pitfalls
+
+### Pitfall 1: KeepAlive `include`/`exclude` name-matching bug with async (lazy-loaded) route components
+**What goes wrong:** ``'s name matching can fail to recognize
+async components (i.e. anything loaded via `component: () => import(...)`) — either
+caching everything regardless of `include`, or caching nothing.
+**Why it happens:** Vue historically matched names against the async-component wrapper
+vnode rather than the resolved inner component; ALL 44 routes in this codebase's
+`router/index.ts` use `component: () => import(...)` [VERIFIED: grep of
+neode-ui/src/router/index.ts], so this bug class applies directly here if unpatched.
+**How to avoid:** The fix (extracting the name from `__asyncResolved`) is reported merged
+and the project pins `vue: ^3.5.24`, which should include it [CITED:
+github.com/vuejs/core/issues/11764] — but confirm with a manual smoke test (navigate to
+an `include`-listed tab, confirm no refetch/remount on revisit; navigate to a
+NOT-included tab, confirm it DOES remount) before trusting `include`/`exclude` filtering
+in this codebase. If it misbehaves, `` with no `include`/`exclude` at
+all (letting LRU eviction do the exclusion work) is documented as working correctly with
+async components (only `include`/`exclude` were broken), and is a safe fallback for this
+phase.
+**Warning signs:** A tab listed in `include` still shows a spinner/remounts every visit
+during manual verification, or a tab NOT in `include` unexpectedly retains scroll
+position/state across visits.
+
+### Pitfall 2: The real remount point is nested inside `Dashboard.vue`, not `App.vue`
+**What goes wrong:** Wrapping `App.vue`'s outer `` (line ~8, per CONTEXT.md's
+canonical_refs) produces no visible improvement.
+**Why it happens:** `App.vue`'s RouterView only ever swaps between top-level route
+components (`OnboardingWrapper.vue`, `Dashboard.vue`, `NotFound.vue`) — Dashboard itself
+stays mounted for the entire authenticated session. The actual per-tab remount churn is a
+SECOND, nested `` inside `Dashboard.vue` (`neode-ui/src/views/Dashboard.vue:87`),
+which ALSO keys its wrapper `` by `route.path` for `
` purposes and
+branches into two different template paths (chat/mesh vs. everything else).
+**How to avoid:** Scope the KeepAlive work explicitly against `Dashboard.vue`'s nested
+RouterView, in both of its template branches. Update the phase's canonical reference for
+future agents.
+**Warning signs:** After "adding KeepAlive," tab switches still show the intro
+animation/spinner every time, or profiling still shows a full component-tree teardown on
+tab switch.
+
+### Pitfall 3: `useCachedResource` has no reactivation hook — background refresh silently stops working once KeepAlive lands
+**What goes wrong:** D-01's "renders from cache immediately, refreshes in background" only
+half-works: the cache-hit instant paint works (KeepAlive preserves the mounted instance
+and its data), but the "refreshes in background" half depends on `refreshIfStale()` being
+re-invoked on revisit — and today that only happens at setup time (once) and on window
+`focus` events (which don't fire on in-SPA tab switches).
+**Why it happens:** The hook was written before any component used KeepAlive, so it never
+needed `onActivated`/`onDeactivated`. `onScopeDispose` (used for cleanup today) never fires
+for a KeepAlive'd component on tab-away — the component is deactivated, not unmounted.
+**How to avoid:** Add `onActivated(() => refreshIfStale())` to the hook (Pattern 2 above) —
+safe to do unconditionally since Vue no-ops these hooks for components outside a
+`` boundary.
+**Warning signs:** A KeepAlive'd tab shows correct instant-paint on revisit but NEVER shows
+the subtle "refreshing" indicator (D-05) even after the TTL has clearly elapsed, and data
+visibly goes stale (e.g. mesh peer status frozen at whatever it was on last visit).
+
+### Pitfall 4: `onMounted`-based one-shot logic breaks silently once a view is KeepAlive'd
+**What goes wrong:** Per-visit setup that assumed `onMounted` fires on every tab entry
+(scroll restoration, connection-timeout timers, animation-completion flags) stops running
+after the first visit.
+**Why it happens:** `onMounted` fires exactly once per component instance; KeepAlive
+reuses the instance, so it fires exactly once, ever, for that tab's lifetime in the
+session. Concretely: `Apps.vue`'s `connectionTimer` (a 15s timeout that only arms once,
+`onMounted`) and `Server.vue`'s 7-call `onMounted(() => { checkTorStatus(); ...
+loadFipsSummary() })` initializer would both need review — the latter is fine to leave in
+`onMounted` if all 7 calls should only ever run once per session (first visit), but WOULD
+need to move to `onActivated` if any of them should re-run/re-check on every tab revisit.
+**How to avoid:** During each view's conversion (D-02), explicitly decide per side-effect:
+"once ever" stays in `onMounted`; "every visit" moves to `onActivated`.
+**Warning signs:** A feature that worked on first tab visit stops updating/re-arming on
+subsequent visits within the same session.
+
+### Pitfall 5: Serial `await` chains masquerading as "already async"
+**What goes wrong:** Code that looks async-savvy (`async function`, `await` everywhere)
+can still be a serial waterfall if each `await` blocks the next independent call.
+**Why it happens:** Found concretely in `ContainerAppDetails.vue:169-172`:
+`await loadContainer(); await loadLogs(); await loadHealthStatus()` — three independent
+RPC-backed loads, each fully round-tripping before the next starts. `Server.vue`'s
+`onMounted` (non-async, fire-and-forget calls) and `Mesh.vue`'s `onMounted`
+(`await Promise.all([...])`) are both already correctly parallel and should NOT be
+"fixed" — profiling must distinguish real waterfalls from already-parallel code before
+touching it (D-02).
+**How to avoid:** `Promise.all`/`Promise.allSettled` for independent loads (Pattern 3).
+**Warning signs:** DevTools Network/Performance panel shows RPC calls for one screen
+starting one-after-another rather than overlapping.
+
+### Pitfall 6: KeepAlive memory growth on low-power fleet nodes
+**What goes wrong:** Every kept-alive tab holds its full component tree (refs, computed
+caches, and for Mesh specifically a live D3 force-graph + Leaflet map instance) in memory
+indefinitely.
+**Why it happens:** `` with no `max` never evicts. Mesh.vue is 2,651 lines and
+pulls in both `d3` and `@vue-leaflet/vue-leaflet`/`leaflet` [VERIFIED:
+neode-ui/package.json + neode-ui/src/views/Mesh.vue] — one of the heaviest views in the
+app, and one D-03 explicitly wants kept alive anyway (with eviction).
+**How to avoid:** Set `max` per D-03 (Claude's discretion on the exact number — this
+research recommends starting around 8, one less than the 10 entries in `TAB_ORDER` from
+`useRouteTransitions.ts`, so the least-recently-used main tab evicts under memory
+pressure rather than every tab staying resident forever) and verify actual memory
+behavior on archi-dev-box, not just the dev workstation, per D-11.
+**Warning signs:** Memory growth over a session of visiting many tabs; sluggishness
+returning after extended use even though individual tab switches feel fast at first.
+
+### Pitfall 7: `isDetailRoute` (the existing "is this a secondary screen" helper) doesn't cover every secondary screen
+**What goes wrong:** `neode-ui/src/views/dashboard/useRouteTransitions.ts`'s
+`isDetailRoute()` only recognizes `/apps/:id` and `/marketplace/:id` as detail routes.
+Routes like `cloud/:folderId`, `cloud/peers/:peerId?`, `server/openwrt`, `goals/:goalId`,
+`app-session/:appId`, `web5/credentials`, `web5/networking-profits`,
+`apps/lnd/channels` are ALSO secondary screens per the phase's own definition
+("screens reached from a tab's main page") but this helper won't flag them.
+**Why it happens:** The helper was written for background-image/transition purposes, not
+for KeepAlive scoping, and was never meant to be an exhaustive secondary-screen registry.
+**How to avoid:** Don't reuse `isDetailRoute` as the source of truth for KeepAlive
+`include`/`exclude`. Build the KeepAlive `include` list explicitly from the known main-tab
+component names (Home, Apps, Mesh, Cloud, Server, Web5, Marketplace/Discover, Chat,
+Settings, Fleet — the `TAB_ORDER` set) rather than trying to derive "not a detail route"
+generically.
+**Warning signs:** A secondary screen unexpectedly gets cached (component instance
+survives navigating away and back) because it slipped through an overly broad `include`
+pattern.
+
+## Code Examples
+
+### Existing SWR usage to mirror (already production code — do not reinvent)
+```typescript
+// Source: neode-ui/src/views/Cloud.vue (existing, verified) — the pattern main tabs
+// being converted should follow.
+const peersResource = useCachedResource({
+ key: 'cloud.peers',
+ fetcher: (signal) => rpcClient.call({ method: 'federation.list-nodes', signal, dedup: true }),
+ // ttlMs left at hook default (30s) here; tune shorter/longer per D-06 as needed
+})
+
+async function loadPeers() {
+ await peersResource.refresh()
+ const e = peersResource.entry
+ if (e.error) loadError.value = e.error // keep-last-known-value; surface error in a banner, not a toast
+}
+```
+
+### rpc-client dedup — already available, use for any newly-parallelized group
+```typescript
+// Source: neode-ui/src/api/rpc-client.ts (existing)
+const res = await rpcClient.call<{ interfaces: NetworkInterface[] }>({
+ method: 'network.list-interfaces',
+ signal,
+ dedup: true, // collapses concurrent identical calls from multiple mounted consumers
+ maxRetries: 1,
+})
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|---------------|--------|
+| Every tab switch fully unmounts/remounts the view (`fetch-on-mount` every visit) | `` + `useCachedResource` stale-while-revalidate | This phase | Instant re-paint from cached instance + data on revisit; RPC only fires when actually stale |
+| Ad-hoc per-view fetch-on-mount with raw `rpcClient.call` (no cache) | `useCachedResource` keyed resource, shared `resources` Pinia store | Already the pattern for 8 views (Monitoring, Cloud, Server, Federation, Credentials, Web5, FIPS cards); this phase extends it to main tabs + remaining secondary screens | Consistent stale-while-revalidate behavior app-wide instead of two different data-loading philosophies coexisting |
+| Serial `await` chains for independent loads (e.g. `ContainerAppDetails.vue`) | `Promise.all`/`Promise.allSettled` | This phase, per-surface as profiling names it (D-02/D-13) | Removes N×round-trip latency stacking into N round-trips' worth |
+
+**Deprecated/outdated:** N/A — no library version is being deprecated; this is closing a
+gap between an already-modern pattern (used in 8 views) and the views that predate it
+(the main tabs, added before `useCachedResource` existed).
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Vue 3.5.24 (as pinned, `^3.5.24`) includes the fix for the KeepAlive `include`/`exclude` async-component name-matching bug (vuejs/core #7533/#11764) | Standard Stack, Pitfall 1 | If not actually fixed at the resolved `^3.5.24` patch version, `include`/`exclude`-based KeepAlive scoping could silently cache the wrong views or none at all; the `max`-only fallback noted in Pitfall 1 is the mitigation — verify manually before trusting `include` |
+| A2 | KeepAlive `max` starting value of ~8 is a reasonable default for archi-dev-box-class hardware | Pitfall 6 | Too high risks memory growth on low-power fleet nodes (D-03's stated concern); too low risks evicting a tab the user just switched away from, defeating the "instant on revisit" goal — this is explicitly Claude's discretion per CONTEXT.md and should be tuned against real on-device memory profiling (D-11), not just picked and shipped |
+| A3 | The 7 unawaited-but-concurrent calls in `Server.vue`'s `onMounted` (line 831) are all safe to run in parallel with no ordering dependency | "Concrete Findings" implied by Pitfall 5 | If any of the 7 (`checkTorStatus`, `loadNetworkData`, `loadInterfaces`, `loadDiskStatus`, `loadTorServices`, `loadVpnPeers`, `loadFipsSummary`) secretly depends on another's side effect, treating this as "already fine, don't touch" during profiling could miss a real bug — profiling (D-10) should still name Server explicitly rather than skip it on the assumption this code is already optimal |
+
+**AIUI ride-along (D-14) is tracked as an Open Question, not an Assumption, below** —
+it's a missing-dependency finding (verified via filesystem check), not a training-data
+guess.
+
+## Open Questions
+
+1. **Where does the AIUI source actually live, and does it already support the D-14
+ query-param-style controls?**
+ - What we know: `apps/aiui/manifest.yml` only describes a prebuilt container image
+ (`localhost/archipelago-aiui:latest`) — no source in this checkout. `neode-ui`'s own
+ `package.json` `dev:mock` script and `scripts/setup-aiui-server.sh` both reference a
+ SIBLING repo at `../../AIUI` (i.e., `/AIUI`) as AIUI's real source,
+ with `dev:mock` gracefully falling back to "chat will show placeholder" when it's
+ absent [VERIFIED: read of both files]. A direct filesystem check on this machine
+ found NO `AIUI` directory next to `archy` [VERIFIED: `ls` of the parent directory].
+ `Chat.vue` already constructs the iframe URL with query params
+ (`embedded=true&hideClose=true&mockArchy=1&seed=1`), so the mechanism for passing a
+ "start expanded" / "start on chat view" flag into AIUI from neode-ui plausibly exists
+ as a pattern, but whether AIUI's OWN code currently reads/honors such params for
+ expanded-state or mobile-view-default is unknown without that source.
+ - What's unclear: Whether AIUI's source is checked out on the machine that will
+ actually execute this phase's plans (the user's memory notes the ThinkPad, `.116`, as
+ the primary build server — AIUI may live there and not on whatever machine ran this
+ research), and whether it already has a query-param or postMessage hook for these two
+ UX defaults or needs new code added on the AIUI side.
+ - Recommendation: The planner should add an early checkpoint/precondition task for D-14
+ confirming AIUI source location and current param support BEFORE scoping the actual
+ UX-default change as line-of-code work — this is a blocking dependency the phase
+ cannot resolve from within `archy` alone if the source truly isn't reachable from the
+ execution environment.
+
+2. **What is the precise profiling methodology/output format for the D-10 findings doc?**
+ - What we know: D-10 requires a COMMITTED doc, written and committed before fixes land,
+ mapping each slow surface → measured cause → intended fix, for the D-09 surface list.
+ - What's unclear: Whether "measured" means DevTools Performance-panel screenshots/traces
+ attached in the doc, `performance.mark`/`measure` numbers pasted in, or a narrative
+ description backed by code inspection (as this research itself did for several
+ surfaces, below).
+ - Recommendation: Treat this research's "Concrete Findings Per Surface" (next section)
+ as a starting hypothesis set, not a substitute for D-10's own profiling pass — the
+ plan should have the profiling task actually run DevTools/Performance-API timing on
+ archi-dev-box (or at minimum the dev workstation with network throttling toward
+ archi-dev) and record real numbers, since several "looks fine in code" surfaces
+ (e.g. `Mesh.vue`'s already-parallel `Promise.all`) may still be slow for reasons this
+ static read cannot see (render cost of the D3 graph, RPC latency to a real node vs.
+ the mock backend, etc.).
+
+## Concrete Findings Per Surface (seed data for the required PERF-01 profiling pass)
+
+Code-level pre-scan of each D-09 surface, to speed up (not replace) profiling:
+
+| Surface | Uses `useCachedResource` today? | `onMounted` fetch pattern found | Likely cause category (to CONFIRM by profiling) |
+|---|---|---|---|
+| Apps (`Apps.vue`) | No — reads from `useAppStore`'s WebSocket-pushed `packages` state, no per-mount RPC | `onMounted` only arms a 15s connection-timeout timer; no fetch | Likely **remount storm only** (loses scroll/search/tab-selection state and re-runs list sort/filter/animation on every visit) — data itself is already live via WebSocket, not refetched |
+| Marketplace/Discover (the "app store" the user called out specifically) | Not checked this session | Not checked this session | **Needs direct profiling** — user said "often app store," but Apps.vue itself looks WebSocket-backed; the actual store/discover views need their own pass, not inherited from Apps.vue's analysis |
+| Mesh (`Mesh.vue`, 2,651 lines, D3 + Leaflet) | No | `onMounted(async () => { await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) })` — already parallelized | **Uncached fetch + remount storm** (6 RPC groups already run in parallel, so NOT a waterfall — but nothing is cached, so all 6 re-run on every tab revisit; plus a D3/Leaflet re-render cost on every remount) |
+| Cloud (`Cloud.vue`) | Yes — `peersResource`/`countsResource` already on `useCachedResource` | `onMounted(async () => { loadCounts(); await loadPeers(); void loadPeerFiles() })` — mostly cache-gated already | Likely **already close to fixed**; profiling may show this needs little to no work, or only KeepAlive (component-instance persistence), not new caching |
+| Server (`Server.vue`, 889 lines) | Partially (some sub-cards use the hook per grep) | `onMounted(() => { checkTorStatus(); loadNetworkData(); loadInterfaces(); loadDiskStatus(); loadTorServices(); loadVpnPeers(); loadFipsSummary() })` — 7 independent fire-and-forget calls, already effectively parallel (not `await`ed serially) | **Uncached fetch** (7 RPC calls fire fresh on every visit; already parallel, so not a waterfall) |
+| ContainerAppDetails (secondary screen for installed apps) | No | `onMounted(async () => { await loadContainer(); await loadLogs(); await loadHealthStatus() })` | **Confirmed serial RPC waterfall** — concrete Pattern 3 fix candidate |
+| AppDetails (secondary screen) | No | `onMounted(() => { loadBitcoinSync(); loadCredentials() })` — both fire-and-forget, not awaited sequentially | Likely fine as-is (already parallel); candidate for `useCachedResource` conversion per D-04 mainly for the instant-repeat-visit requirement (PERF-03), not a waterfall fix |
+| Wallet / send flows | Not located/checked this session (no `Wallet.vue` found under `views/`; likely spread across `AppDetails.vue`'s Bitcoin-app-specific code and modal components) | Not checked | **Needs direct profiling** — locate the actual wallet/send-flow components first |
+| Web5 (`web5/Web5.vue`) | Yes (in the useCachedResource grep list) | Not inspected in detail this session | Likely **already close to fixed**; confirm via profiling rather than assuming |
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js | Frontend build/dev | ✓ | v24.14.1 [VERIFIED: `node --version`] | — |
+| npm | Frontend build/dev | ✓ | 11.11.0 [VERIFIED: `npm --version`] | — |
+| Vitest | Unit tests for the `onActivated` hook extension | ✓ (already configured, `neode-ui/vitest.config.ts`) | 3.1.1 [VERIFIED: package.json] | — |
+| Playwright | Optional e2e smoke of tab-switch behavior | ✓ (`neode-ui/e2e/`) | 1.58.2 [VERIFIED: package.json] | Manual verification on archi-dev-box is the phase's actual pass bar (D-11) regardless |
+| archi-dev-box (on-device verification target, D-11) | PERF-01/02/03 sign-off | ✓ — resolvable from this environment (multiple addresses returned) [VERIFIED: `getent hosts archi-dev-box`] | — | — |
+| AIUI source repo (`../../AIUI`, sibling to `archy`) | D-14 UX defaults | ✗ — not present on this machine [VERIFIED: `ls` of parent directory] | — | Must be located (possibly on the ThinkPad `.116` build server per project memory) before D-14 can be implemented as code — see Open Question 1 |
+
+**Missing dependencies with no fallback:**
+- AIUI source repo for D-14 — there is no in-repo fallback; the two small UX defaults
+ cannot be implemented without either the AIUI source or a confirmed existing
+ neode-ui-side hook (iframe query param) that AIUI already honors.
+
+**Missing dependencies with fallback:**
+- None of the frontend-perf-specific work (KeepAlive, useCachedResource extension,
+ Promise.all fixes) has any missing dependency — everything needed is already installed.
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Vitest 3.1.1 [VERIFIED: package.json], jsdom environment, globals enabled |
+| Config file | `neode-ui/vitest.config.ts` |
+| Quick run command | `npm run test -- .test.ts` (run from `neode-ui/`) |
+| Full suite command | `npm run test` (from `neode-ui/`); `npm run test:watch` for iteration |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| PERF-01 | Slow surfaces profiled, causes named before fixes land | manual (committed findings doc, D-10) | N/A — not an automatable assertion; verified by the doc's existence + review | ❌ Wave 0 — this is a doc deliverable, not a test |
+| PERF-02 | Main-tab component instance NOT recreated on revisit; cached data renders synchronously | unit (`@vue/test-utils` + Vitest, mounting `Dashboard.vue`'s router-view structure or an isolated harness around ``) | `npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts` (NEW) | ❌ Wave 0 — needs a new test file |
+| PERF-02 | `useCachedResource`'s `onActivated` extension actually calls `refreshIfStale()` on reactivation, and is a no-op when not inside `` | unit | `npm run test -- src/composables/__tests__/useCachedResource.test.ts` (NEW — no existing test file for this composable was found) | ❌ Wave 0 |
+| PERF-03 | Repeat opens of a secondary screen (e.g. AppDetails) resolve from cache without re-invoking the fetcher | unit (spy call count assertion on the fetcher passed to `useCachedResource`) | `npm run test -- src/views/__tests__/AppDetails.test.ts` (NEW, or extend an existing view test if one exists) | ❌ Wave 0 |
+| PERF-03 (on-device pass bar, D-11) | No visible spinner/blank on revisit of any tab/secondary screen already visited this session, on archi-dev-box | manual only | N/A — inherently a live/manual on-device check per the phase's own success criteria | ❌ Wave 0 — manual-only by design, not a gap to fill with automation |
+
+### Sampling Rate
+- **Per task commit:** `npm run test -- ` (quick, targeted)
+- **Per wave merge:** `npm run test` (full Vitest suite) + a manual archi-dev-box
+ tab-switch/secondary-screen walkthrough for whatever surfaces that wave touched
+- **Phase gate:** Full Vitest suite green, PLUS the committed D-10 findings doc exists and
+ is referenced, PLUS a full manual pass-bar walkthrough on archi-dev-box per D-11 before
+ `/gsd-verify-work`
+
+### Wave 0 Gaps
+- [ ] `neode-ui/src/composables/__tests__/useCachedResource.test.ts` — no existing test
+ file for this composable; needed to cover the new `onActivated` behavior and confirm
+ existing sticky-ready/keep-last-value/dedup semantics aren't regressed by the change
+- [ ] `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — new test harness for
+ the KeepAlive-wrapped nested router-view (component identity survives a simulated route
+ change to an `include`-listed path; does NOT survive for a non-included path)
+- [ ] A committed profiling findings doc at
+ `.planning/phases/02-ui-performance/` (per D-10) — this is a deliverable, not a test
+ file, but Wave 0 of the plan should treat "produce this doc" as a hard prerequisite
+ gating all per-surface fix waves, per D-02/D-10
+
+## Security Domain
+
+`security_enforcement` is absent from `.planning/config.json` → treated as enabled.
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | No | This phase touches no auth/session code paths |
+| V3 Session Management | No | No session logic changes |
+| V4 Access Control | Conditional — only if D-12/D-13 leads to a NEW aggregate RPC endpoint | Any new endpoint must sit behind whatever RBAC/session middleware existing sibling `core/` RPC handlers already use — additive-only per D-12 means copying the existing auth-check pattern, not designing a new one |
+| V5 Input Validation | Conditional — same trigger as V4 | Any new aggregate endpoint's parameters must be validated the same way existing handlers validate theirs (existing project convention, not a new library) |
+| V6 Cryptography | No | Nothing in this phase touches crypto/secrets |
+
+### Known Threat Patterns for this stack
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| A new aggregate RPC endpoint accidentally exposing more fields than the requesting view needs (over-fetch through a "convenience" batch endpoint) | Information Disclosure | Shape the aggregate response to exactly what the calling screen renders — mirror the discipline already visible in existing typed RPC response interfaces in `rpc-client.ts` |
+| sessionStorage snapshot (D-08) persisting sensitive per-user data longer than intended | Information Disclosure | Already mitigated by the existing `persist: false` opt-out for large/sensitive payloads (D-08); when converting a new view, explicitly decide persist:true/false rather than defaulting blindly |
+
+No new authentication, authorization, or cryptographic surfaces are introduced by this
+phase — the security review here is narrow by design, matching the phase's actual scope.
+
+## Sources
+
+### Primary (HIGH confidence)
+- `neode-ui/src/composables/useCachedResource.ts`, `neode-ui/src/stores/resources.ts`,
+ `neode-ui/src/api/rpc-client.ts`, `neode-ui/src/App.vue`, `neode-ui/src/views/Dashboard.vue`,
+ `neode-ui/src/views/dashboard/useRouteTransitions.ts`, `neode-ui/src/router/index.ts`,
+ `neode-ui/src/views/{Apps,Mesh,Cloud,Server,AppDetails,ContainerAppDetails}.vue`,
+ `neode-ui/package.json`, `neode-ui/vitest.config.ts`, `apps/aiui/manifest.yml`,
+ `scripts/setup-aiui-server.sh` — all read directly this session.
+- [RouterView slot | Vue Router](https://router.vuejs.org/guide/advanced/router-view-slot) —
+ confirms the scoped-slot KeepAlive/Transition pattern is the only supported form in
+ Vue Router 4.
+- [KeepAlive | Vue.js](https://vuejs.org/guide/built-ins/keep-alive) — `include`/`exclude`
+ name matching, `max` LRU eviction, `activated`/`deactivated` hook semantics.
+
+### Secondary (MEDIUM confidence)
+- [KeepAlive include/exclude parameters do not work as expected for asynchronously loaded router views · Issue #11764 · vuejs/core](https://github.com/vuejs/core/issues/11764) —
+ WebFetch-summarized; describes the bug and states it was fixed by extracting the name
+ from `__asyncResolved` — the exact patch-version boundary was not independently
+ cross-verified against a changelog this session (see Assumption A1).
+- WebSearch results on `onActivated`/`onDeactivated` being a safe no-op outside
+ `` (multiple Vue lifecycle-hook explainer articles, cross-checked against the
+ official Vue lifecycle-hooks API reference).
+
+### Tertiary (LOW confidence)
+- None — no unverified WebSearch-only claims were used to make a prescriptive
+ recommendation in this document; the one external claim with residual uncertainty (A1)
+ is explicitly flagged with a manual-verification mitigation rather than presented as fact.
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — no new libraries; every primitive cited was read directly from
+ the codebase.
+- Architecture: HIGH — the KeepAlive insertion point, the `onActivated` gap, and the
+ serial-waterfall example were all found by direct code inspection, not inferred.
+- Pitfalls: HIGH for pitfalls 1–7 (code-grounded); MEDIUM for the exact Vue patch-version
+ boundary in Pitfall 1 (external GitHub issue, not independently changelog-verified).
+- AIUI ride-along (D-14): LOW — genuinely blocked pending source-location confirmation;
+ flagged as Open Question 1, not glossed over.
+
+**Research date:** 2026-07-30
+**Valid until:** 2026-08-13 (30 days is generous for a fast-moving frontend area; if the
+AIUI repo location or the Vue KeepAlive patch status changes, re-verify before relying on
+this doc)