diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 25acf261..8b8f511f 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -64,7 +64,17 @@ Plans: 2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session 3. Secondary screens open without a blocking full reload; repeat visits are instant 4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device -**Plans**: TBD +**Plans**: 8 plans + +Plans: +- [ ] 02-01-PLAN.md — Profile every D-09 surface on archi-dev-box and commit the findings doc (PERF-01) +- [ ] 02-02-PLAN.md — TRACER: KeepAlive host, hook reactivation, app-store tab, refresh indicator (PERF-02) +- [ ] 02-03-PLAN.md — Secondary screens: per-item cache, parallel loads, purge on logout (PERF-03) +- [ ] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) +- [ ] 02-05-PLAN.md — Mesh: cache the six fetch groups, bound the D3 graph and Leaflet map (PERF-02) +- [ ] 02-06-PLAN.md — Server and Home: cache the uncached fan-out, guarantee wallet freshness (PERF-02) +- [ ] 02-07-PLAN.md — Chat/AIUI: stable embed URL + the two D-14 UX defaults (PERF-02) +- [ ] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03) **UI hint**: yes ### Phase 3: Multinode Verification Pass @@ -144,7 +154,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 1. Federation & Mesh Hardening | 0/10 | Planned | - | -| 2. UI Performance | 0/TBD | Not started | - | +| 2. UI Performance | 0/8 | Planned | - | | 3. Multinode Verification Pass | 0/TBD | Not started | - | | 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | | 5. Registry-Distributed Manifests | 0/TBD | Not started | - | diff --git a/.planning/phases/02-ui-performance/02-01-PLAN.md b/.planning/phases/02-ui-performance/02-01-PLAN.md new file mode 100644 index 00000000..5ef9f046 --- /dev/null +++ b/.planning/phases/02-ui-performance/02-01-PLAN.md @@ -0,0 +1,368 @@ +--- +phase: 02-ui-performance +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - neode-ui/e2e/perf/surfaces.ts + - neode-ui/e2e/perf/measure.ts + - neode-ui/e2e/perf/surface-perf.spec.ts + - .planning/phases/02-ui-performance/02-PERF-BASELINE.json + - .planning/phases/02-ui-performance/02-FINDINGS.md +autonomous: true +requirements: [PERF-01] + +must_haves: + truths: + - "Every surface in the D-09 starting set (Apps, Marketplace/Discover app store, Mesh, Wallet/send flows, Cloud/Files, Server, Network, Web5, plus AppDetails) has a recorded first-visit and revisit measurement in 02-PERF-BASELINE.json" + - "For each measured surface the findings doc names exactly one primary cause drawn from the closed set: remount storm, serial RPC waterfall, uncached fetch, or already-fast" + - "Each named cause is backed by a number in 02-PERF-BASELINE.json (revisit ms, revisit RPC count, or remount-probe result) — not by code reading alone" + - "02-FINDINGS.md is committed to git before any production source file under neode-ui/src is modified by this phase (D-10)" + - "The harness re-runs on demand and produces a comparable JSON artifact, so the same measurements can be taken again after the fixes land" + - "A surface that could not be measured is recorded in the findings doc as unmeasured with the reason, and is never recorded as already-fast" + - statement: "Running the harness twice against an unchanged build yields the same primary-cause classification for every surface" + verification: backstop + prohibitions: + - "MUST NOT present inferred, code-read, or cherry-picked numbers as measured profiling results, and MUST NOT omit a D-09 surface from the findings doc because it was hard to measure — an unmeasured surface is recorded as unmeasured, never as already-fast" + - "MUST NOT ship performance instrumentation that transmits, logs, or persists node or user activity off-device — profiling stays local to the developer's browser and harness run" + - "MUST NOT commit node-identifying or account-identifying material (onion addresses, DIDs, pubkeys, wallet balances, file names, peer hostnames) into the profiling artifacts" + artifacts: + - path: "neode-ui/e2e/perf/surfaces.ts" + provides: "SURFACES table — one row per D-09 surface with route path, content selector and view-root selector" + exports: ["SURFACES", "type Surface"] + - path: "neode-ui/e2e/perf/measure.ts" + provides: "measureSurface() — first-visit vs revisit timing, RPC request trace, remount probe" + exports: ["measureSurface", "type SurfaceMeasurement", "type RpcCall"] + - path: "neode-ui/e2e/perf/surface-perf.spec.ts" + provides: "Playwright spec that walks every SURFACES row and writes the JSON artifact" + - path: ".planning/phases/02-ui-performance/02-PERF-BASELINE.json" + provides: "Recorded pre-fix measurements for every D-09 surface" + - path: ".planning/phases/02-ui-performance/02-FINDINGS.md" + provides: "D-10 deliverable — surface, measured cause, intended fix, owning plan" + key_links: + - from: "neode-ui/e2e/perf/surface-perf.spec.ts" + to: "neode-ui/e2e/perf/measure.ts" + via: "imports measureSurface and calls it once per SURFACES row" + pattern: "measureSurface" + - from: "neode-ui/e2e/perf/measure.ts" + to: "browser network layer" + via: "page.on('request') / page.on('response') captures RPC POSTs so revisit RPC count is observed, not assumed" + pattern: "page\\.on\\(['\"]request" + - from: ".planning/phases/02-ui-performance/02-FINDINGS.md" + to: ".planning/phases/02-ui-performance/02-PERF-BASELINE.json" + via: "every named cause cites the baseline row it came from" + pattern: "02-PERF-BASELINE" +--- + + +Measure the D-09 surfaces on real node hardware, name each one's primary cause from +observed numbers, and commit the findings doc that gates every fix in this phase. + +Purpose: PERF-01 and D-10 require that fixes are targeted, not guessed. Nothing under +`neode-ui/src/` changes in this plan — the harness lives entirely in `neode-ui/e2e/perf/` +so the "measure before you fix" ordering is structurally guaranteed rather than merely +promised. + +Output: a re-runnable Playwright perf harness, a committed baseline JSON, and +`02-FINDINGS.md` mapping each surface to a measured cause and the plan that fixes it. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 +@CLAUDE.md + + + + + + Task 1: Build the re-runnable surface perf harness + neode-ui/e2e/perf/surfaces.ts, neode-ui/e2e/perf/measure.ts, neode-ui/e2e/perf/surface-perf.spec.ts + + - neode-ui/playwright.config.ts — testDir is `./e2e`, baseURL comes from `ARCHY_BASE_URL` (default `http://192.168.1.228`), single `chromium` project, 60s timeout + - neode-ui/e2e/app-launch.spec.ts — the existing login/navigation flow to reuse verbatim; do not invent a second auth path + - neode-ui/e2e/intro-experience.spec.ts — how the existing specs get past the intro/splash gating so a measurement does not accidentally time the intro animation + - neode-ui/src/router/index.ts — the authoritative route table; every `SURFACES` path must exist here + - neode-ui/src/views/dashboard/useRouteTransitions.ts — `TAB_ORDER` is the canonical main-tab path list + - neode-ui/src/api/rpc-client.ts — the RPC transport, so the request filter matches the real endpoint shape rather than a guess + + + Create `neode-ui/e2e/perf/surfaces.ts` exporting `type Surface` and a `SURFACES` + array. One row per D-09 surface, each row carrying: `id`, `label`, `path`, + `kind` (`main-tab` or `secondary`), `contentSelector` (a selector that is only + present once real content has painted, not a skeleton or spinner), and + `rootSelector` (the stable outermost element of the view, used by the remount probe). + Rows required by D-09, using the real route paths from `router/index.ts`: + `/dashboard` (home/wallet figures), `/dashboard/apps`, `/dashboard/marketplace`, + `/dashboard/discover`, `/dashboard/cloud`, `/dashboard/mesh`, `/dashboard/server`, + `/dashboard/web5`, `/dashboard/fleet`, `/dashboard/chat`, plus secondary rows + `/dashboard/apps/:id` (AppDetails), `/dashboard/marketplace/:id` + (MarketplaceAppDetails), `/dashboard/cloud/:folderId` (CloudFolder) and + `/dashboard/server/openwrt` (OpenWrtGateway). Derive each `contentSelector` and + `rootSelector` by reading the corresponding view file; prefer an existing stable + class or a `data-` attribute already present over adding markup to `src/`. + + D-09 also names "Wallet / send flows". RESEARCH.md could not locate a `Wallet.vue`. + Locate the real wallet surface first (grep for `SendBitcoinModal`, `loadWeb5Status`, + and wallet balance rendering under `neode-ui/src/views` and + `neode-ui/src/components`), then add a row for wherever wallet figures and the send + entry point actually live. If the wallet surface turns out to be a modal rather than + a route, add a row with a `trigger` field naming the selector that opens it, and + measure open-to-content instead of navigate-to-content. + + Create `neode-ui/e2e/perf/measure.ts` exporting `measureSurface(page, surface, + opts)` returning a `SurfaceMeasurement`. It must record, per surface: + `firstVisitMs` (navigate from the dashboard home to the surface, wait for + `contentSelector`), `revisitMs` (navigate away to a fixed neutral tab, then back, + wait for `contentSelector`), `firstVisitRpcCount` and `revisitRpcCount` (POSTs + captured via `page.on('request')`, filtered to the RPC endpoint the rpc-client uses), + `revisitRpcCalls` (an ordered array of `{ method, startedAtMs, durationMs }` so + overlapping vs. sequential call timing is visible in the artifact), `remounted` + (see below), and `error` (a string when the surface could not be measured, with + the other numeric fields left null). Take `runs` samples per surface (default 3) + and record every sample plus the median — never only the best one. + + Implement the remount probe without touching `src/`: on the first visit, in the + page context, stamp the element matched by `rootSelector` with a unique value on a + dataset key (for example `perfProbe`); after the away-and-back navigation, read the + same key back. A surviving value means the component instance was reused; a missing + value means the view remounted. Record the raw before/after values in the + measurement so the conclusion is auditable. + + Derive the serial-vs-parallel signal from `revisitRpcCalls`: expose a computed + `maxConcurrentRpc` and `rpcWallClockMs` on the measurement so a waterfall (calls + starting one after another, `maxConcurrentRpc` of 1) is distinguishable from an + already-parallel fan-out without re-reading the code. + + Create `neode-ui/e2e/perf/surface-perf.spec.ts`: a single Playwright test that logs + in using the flow from `app-launch.spec.ts`, iterates `SURFACES`, calls + `measureSurface` for each, and writes the full result array plus a run header + (`baseUrl`, `takenAt`, `commit` from `git rev-parse --short HEAD`, `runs`) to the + path given by the `ARCHY_PERF_OUT` environment variable, defaulting to + `e2e/test-results/surface-perf.json`. A surface that throws is caught, recorded + with its `error` string, and does not abort the remaining surfaces. + + Redaction is part of the harness, not a later cleanup step: the measurement must + record RPC method names and timings only. Do not capture request bodies, response + bodies, page text, or screenshots into the JSON artifact. + + + cd neode-ui && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v 'e2e/test-results' ; npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium --reporter=line + + + - `neode-ui/e2e/perf/surfaces.ts` exports `SURFACES` and every entry's `path` appears in `neode-ui/src/router/index.ts` + - `SURFACES` contains at least one row for each of: apps, marketplace, discover, cloud, mesh, server, web5, chat, home, fleet, and the located wallet surface + - `neode-ui/e2e/perf/measure.ts` exports `measureSurface` and the file contains `page.on('request'` + - `SurfaceMeasurement` carries all of `firstVisitMs`, `revisitMs`, `firstVisitRpcCount`, `revisitRpcCount`, `revisitRpcCalls`, `maxConcurrentRpc`, `remounted`, `samples`, `error` + - `npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium` exits 0 and writes a JSON file whose top-level array length equals `SURFACES.length` + - The written JSON contains no request or response body text: `node -e "const r=require('./e2e/test-results/surface-perf.json');process.exit(JSON.stringify(r).match(/onion|did:|xpub|npub/i)?1:0)"` exits 0 + - `npx tsc --noEmit` reports no errors originating in `e2e/perf/` + + The harness runs end to end against a reachable Archipelago node and emits a complete, body-free measurement artifact for every D-09 surface. + + + + Task 2: Record the on-device baseline from archi-dev-box + archi-dev-box resolves and its web UI answers over HTTP from this machine (`getent hosts archi-dev-box` returns at least one address and the base URL returns a 200/302 for `/`) + .planning/phases/02-ui-performance/02-PERF-BASELINE.json + + - neode-ui/e2e/perf/surface-perf.spec.ts — the harness written in Task 1, for its env-var contract + - neode-ui/playwright.config.ts — `ARCHY_BASE_URL` is the target override + - scripts/dev-start.sh — how the :8100 dev preview is started, in case the node's own UI is not directly reachable and the preview must proxy to it + - .planning/phases/02-ui-performance/02-CONTEXT.md — D-11 names archi-dev-box as the verification target and states the pass bar + + + Run the Task 1 harness against archi-dev-box with `ARCHY_BASE_URL` pointed at that + node and `ARCHY_PERF_OUT` set to + `../.planning/phases/02-ui-performance/02-PERF-BASELINE.json`, with `runs` at 3. + archi-dev-box resolves to IPv6 addresses in this environment; if the bare hostname + does not connect, try the reachable address form and record the exact base URL used + in the artifact's run header. + + If the node's UI cannot be driven directly, fall back to the :8100 dev preview + pointed at archi-dev per the phase's existing dev discipline, and record in the run + header that the measurement was taken through the preview rather than against the + node's own served bundle. Do not silently substitute the local mock backend — a + mock-backend run is not an on-device baseline and must be labelled as such in the + header if it is the only run that succeeds. + + Add a `notes` field to the run header recording: the target actually used, the + browser build, whether the node was otherwise idle, and any surface that had to be + skipped and why. Commit the artifact. + + Do not edit any file under `neode-ui/src/` in this task. If a surface cannot be + measured because its `contentSelector` never appears, fix the selector in + `neode-ui/e2e/perf/surfaces.ts` and re-run rather than dropping the surface. + + + node -e "const r=require('/home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-PERF-BASELINE.json'); const rows=r.results??r; if(!Array.isArray(rows)||rows.length===0)process.exit(1); const bad=rows.filter(x=>x.error==null&&(x.revisitMs==null||x.revisitRpcCount==null)); if(bad.length){console.error('incomplete rows',bad.map(b=>b.id));process.exit(1)} console.log('rows',rows.length)" + + + - `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` exists and parses as JSON + - Its run header records `baseUrl`, `takenAt`, `commit`, `runs` and a `notes` string naming the actual target + - Every `SURFACES` row appears in the results, each with either numeric measurements or a non-empty `error` string + - No file under `neode-ui/src/` is modified by this task: `git diff --name-only HEAD -- neode-ui/src | wc -l` prints 0 + - The artifact is committed (`git log -1 --name-only` lists `02-PERF-BASELINE.json`) + + A committed on-device baseline exists covering every D-09 surface, with the measurement target explicitly recorded. + + + + Task 3: Write and commit the D-10 findings doc + .planning/phases/02-ui-performance/02-FINDINGS.md + + - .planning/phases/02-ui-performance/02-PERF-BASELINE.json — the numbers every claim must cite + - .planning/phases/02-ui-performance/02-RESEARCH.md — the "Concrete Findings Per Surface" table is a hypothesis set to confirm or overturn, not a substitute for the measurement + - .planning/phases/02-ui-performance/02-CONTEXT.md — D-02 (targeted not blanket), D-10 (doc before fixes), D-12/D-13 (fix scope) + - .planning/phases/02-ui-performance/02-PATTERNS.md — the per-view conversion checklist the intended fixes should align with + + + Write `.planning/phases/02-ui-performance/02-FINDINGS.md` with these sections: + + 1. `## Method` — the target used, the harness path, the run command with its env + vars, the sample count, and what each recorded field means. State plainly which + surfaces were measured on archi-dev-box and which (if any) were not. + 2. `## Per-Surface Findings` — a table with columns: Surface, First visit (ms, + median), Revisit (ms, median), Revisit RPC count, Max concurrent RPC, Remounted, + Primary cause, Intended fix, Owning plan. `Primary cause` takes exactly one value + from `remount storm`, `serial RPC waterfall`, `uncached fetch`, `already fast`, + or `unmeasured`. Every non-`unmeasured` row cites the baseline field that + justifies the cause (a remount storm needs `remounted: true`; an uncached fetch + needs a non-zero `revisitRpcCount`; a waterfall needs `maxConcurrentRpc` of 1 with + two or more sequential calls). + 3. `## Ranked Fix Order` — the surfaces ordered worst-revisit-first. This ranking + selects the tracer tab for plan 02-02. + 4. `## Surfaces Left Alone (D-02)` — every surface classified `already fast`, with + its numbers, so the decision not to convert it is auditable. + 5. `## Corrections to Prior Research` — record, with evidence, any place the + measurement overturns `02-RESEARCH.md`. At minimum confirm or refute this + planner's finding that `neode-ui/src/views/ContainerAppDetails.vue` has no + importer and no route entry (run `grep -rn "ContainerAppDetails" neode-ui/src` and + paste the result) — RESEARCH.md names it as the confirmed serial-waterfall fix + target, and if it is unreachable then no plan should spend effort on it and the + real waterfalls must come from the measured `revisitRpcCalls` instead. + 6. `## Owning Plans` — map each surface to the plan number that fixes it, using the + plan set for this phase (02-02 tracer/app store, 02-03 secondary screens, 02-04 + keep-alive lifecycle, 02-05 Mesh, 02-06 Server and Home, 02-07 Chat/AIUI). + + Redact before committing: replace any onion address, DID, pubkey, hostname other + than `archi-dev-box`, wallet figure, or file name that appears in a method name or + note with a short placeholder. Method names and timings stay. + + Commit this doc as its own commit, and make that commit the last one in this plan — + it is the gate the rest of the phase depends on. + + + test -f /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md && for s in '## Method' '## Per-Surface Findings' '## Ranked Fix Order' '## Surfaces Left Alone' '## Corrections to Prior Research' '## Owning Plans'; do grep -qF "$s" /home/archipelago/Projects/archy/.planning/phases/02-ui-performance/02-FINDINGS.md || { echo "missing: $s"; exit 1; }; done; echo OK + + + - `02-FINDINGS.md` contains all six required headings + - Every surface present in `02-PERF-BASELINE.json` appears as a row in the Per-Surface Findings table + - Every row's Primary cause is one of the five allowed values + - The doc references `02-PERF-BASELINE.json` at least once + - `## Corrections to Prior Research` contains the literal output of the `ContainerAppDetails` grep + - `git log --oneline -1 -- .planning/phases/02-ui-performance/02-FINDINGS.md` returns a commit + - `git diff --name-only HEAD~3..HEAD -- neode-ui/src | wc -l` prints 0 — no production source changed in this plan + + The findings doc is committed, every claim in it traces to a measured number, and the fix order for the rest of the phase is fixed in writing. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| archi-dev-box node → developer workstation | Real node data (RPC method names, timings, and anything else the harness might capture) crosses onto the workstation and into a git-tracked artifact | +| git-tracked planning artifacts → repository history | Anything written into `02-PERF-BASELINE.json` / `02-FINDINGS.md` is permanent and pushed | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-02-06 | Information Disclosure | `e2e/perf/measure.ts` artifact writer | medium | mitigate | Harness records RPC method names and timings only — no request bodies, response bodies, page text, or screenshots (Task 1); Task 3 adds a redaction pass before commit | +| T-02-07 | Information Disclosure | `02-FINDINGS.md` committed to a pushed repo | medium | mitigate | Explicit redaction step in Task 3 for onion addresses, DIDs, pubkeys, wallet figures, peer hostnames and file names | +| T-02-08 | Spoofing | Playwright login flow reusing dev credentials | low | accept | The harness reuses the existing `e2e/app-launch.spec.ts` auth flow against a developer-owned dev node; no new credential surface is introduced and none are written to the artifact | +| T-02-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package-manager installs are in scope: RESEARCH.md's Package Legitimacy Audit records zero new packages, and every primitive used is Vue core, a JS built-in, or already present. If any task finds it needs a new dependency it stops and routes through the Package Legitimacy Gate with a blocking human checkpoint before installing | + + + +## Artifacts this phase produces + +New symbols and paths created by Phase 02. Newly-created names below are not pre-existing +API and must not be treated as drift from the current codebase. + +**Created by this plan (02-01):** +- `neode-ui/e2e/perf/surfaces.ts` — `SURFACES`, `type Surface` +- `neode-ui/e2e/perf/measure.ts` — `measureSurface()`, `type SurfaceMeasurement`, `type RpcCall` +- `neode-ui/e2e/perf/surface-perf.spec.ts` +- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` +- `.planning/phases/02-ui-performance/02-FINDINGS.md` +- Environment variables consumed: `ARCHY_PERF_OUT` (new), `ARCHY_BASE_URL` (pre-existing in `playwright.config.ts`) + +**Created elsewhere in Phase 02 (for cross-plan reference):** +- `neode-ui/src/views/dashboard/keepAliveRoutes.ts` — `shouldKeepAlive()`, `KEEP_ALIVE_PATHS` +- `neode-ui/src/components/RefreshIndicator.vue` +- `neode-ui/src/composables/__tests__/useCachedResource.test.ts` +- `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` +- `RouteMeta.keepAlive` — `vue-router` module augmentation +- `.planning/phases/02-ui-performance/02-PERF-AFTER.json` + + + +## Assumptions & Flagged Items + +Nothing below is silently dropped. Each row is an explicit flagged assumption carried into +execution. + +### Edge-coverage probe rows (spec-less fallback — all three came back unclassified/unresolved) + +| Requirement | Probe status | Disposition here | +|---|---|---| +| PERF-01 | `unclassified` / `unresolved` — probe could not classify | FLAGGED. Not auto-backstopped. Resolved in substance by the `must_haves.truths` written above (measured-cause coverage, closed cause set, doc-before-fixes ordering, unmeasured-is-not-fast), plus one `verification: backstop` truth for run-to-run classification stability. The probe row itself remains unresolved and is surfaced here for human review. | +| PERF-02 | `unclassified` / `unresolved` | FLAGGED and carried in plans 02-02, 02-04, 02-05, 02-06, 02-07, 02-08. | +| PERF-03 | `unclassified` / `unresolved` | FLAGGED and carried in plans 02-03 and 02-08. | + +### Prohibition-probe canon referrals (breadcrumbed, deliberately not minted) + +- Injection / XSS via cached-then-rendered payloads is canon — covered by `/gsd-secure-phase` and eslint security plugins; not minted here. +- Generic GDPR / data-retention rules for browser storage are canon — covered by `/gsd-secure-phase`; the privacy prohibitions minted in this phase are product-specific payload classes (wallet, credential, peer-content), not the generic rule. + +### Corrections to prior phase artifacts + +| ID | Item | Evidence | Impact | +|---|---|---|---| +| FA-B | `neode-ui/src/views/ContainerAppDetails.vue` appears to be dead code — `grep -rn "ContainerAppDetails" neode-ui/src` returns only a self-referential comment inside the file itself, and the file has no entry in `neode-ui/src/router/index.ts` | Verified 2026-07-30 by this planner | RESEARCH.md names it as the *confirmed* serial-waterfall fix target. If dead, no plan should spend effort on it. Task 3 re-runs the grep and records the verdict. | +| FA-F | CONTEXT.md `canonical_refs` names `neode-ui/src/App.vue` as the KeepAlive insertion point | RESEARCH.md Pitfall 2, confirmed by this planner reading `Dashboard.vue:87-118` | Superseded — the real per-tab remount point is the nested `RouterView` in `Dashboard.vue`. Carried into plan 02-02. | + + + +- `npx playwright test e2e/perf/surface-perf.spec.ts --project=chromium` exits 0 +- `.planning/phases/02-ui-performance/02-PERF-BASELINE.json` covers every `SURFACES` row +- `.planning/phases/02-ui-performance/02-FINDINGS.md` has all six required headings and is committed +- `git diff --name-only ..HEAD -- neode-ui/src` is empty — the D-10 ordering held + + + +- Every D-09 surface has a measured first-visit and revisit number taken against archi-dev-box (or an explicitly labelled fallback target) +- Every surface has exactly one primary cause from the closed set, each backed by a cited baseline field +- The findings doc is committed before any `neode-ui/src` change in this phase +- The harness can be re-run later to produce a directly comparable after-artifact + + + +Create `.planning/phases/02-ui-performance/02-01-SUMMARY.md` when done. Include the +per-surface cause table and the ranked fix order verbatim — downstream plans read the +tracer-tab selection from it. + diff --git a/.planning/phases/02-ui-performance/02-02-PLAN.md b/.planning/phases/02-ui-performance/02-02-PLAN.md new file mode 100644 index 00000000..34c2705f --- /dev/null +++ b/.planning/phases/02-ui-performance/02-02-PLAN.md @@ -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" +--- + + +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. + + + +@$HOME/.claude/gsd-core/workflows/execute-plan.md +@$HOME/.claude/gsd-core/templates/summary.md + + + +@.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 + + + + + + Task 1: One main tab survives a tab round-trip and revalidates on return + `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. + 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/views/Dashboard.vue` — read the template around lines 87-118. The nested `` sits inside ``, whose child is a `
` 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. + + + 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` + 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 `` 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 + `` 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 ``. + + Four structural invariants govern the new template, and the tests below exist to + pin them: + + 1. Nothing between the RouterView slot and `` may carry a binding that + changes identity per route. The current `
` sits exactly + there; if a `` 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 `` outside `` outside + ``. + 3. The `:key="route.path"` binding belongs on `` itself, never on an + ancestor of ``. + 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 + `` branch + and a plain `` 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` 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 `` 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 `` 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 `` 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. + + + cd neode-ui && npm run test -- src/views/dashboard/__tests__/keepAliveTabs.test.ts src/composables/__tests__/useCachedResource.test.ts && npm run type-check + + + - `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) + + 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. + + + + Task 2: Subtle refresh indicator and correct per-visit behavior on the tracer tab + neode-ui/src/components/RefreshIndicator.vue, neode-ui/src/views/Marketplace.vue, neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts + + - `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 `