diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 4c68f092..514e5479 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -53,7 +53,7 @@ signed/decentralized registry and a user installs it on their node.
13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory)
NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes)
-**Plans**: 10 plans
+**Plans**: 18 plans
Plans:
@@ -68,6 +68,23 @@ Plans:
- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03)
- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06)
+**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)*
+
+- [ ] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07)
+- [ ] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02)
+- [ ] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03)
+- [ ] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06)
+- [ ] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05)
+
+**Wave 8** *(blocked on Wave 7 completion)*
+
+- [ ] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07)
+- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01)
+
+**Wave 9** *(blocked on Wave 8 completion)*
+
+- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06)
+
**UI hint**: yes
### Phase 2: UI Performance
diff --git a/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md
new file mode 100644
index 00000000..2a7286a2
--- /dev/null
+++ b/.planning/phases/01-federation-mesh-hardening/01-11-PLAN.md
@@ -0,0 +1,311 @@
+---
+phase: 01-federation-mesh-hardening
+plan: 11
+type: execute
+wave: 7
+depends_on: []
+files_modified:
+ - core/archipelago/src/container/secrets.rs
+ - core/archipelago/src/api/rpc/package/config.rs
+ - core/archipelago/src/api/rpc/package/dependencies.rs
+ - scripts/first-boot-containers.sh
+ - scripts/deploy-to-target.sh
+ - scripts/deploy-tailscale.sh
+ - scripts/reconcile-containers.sh
+ - scripts/container-specs.sh
+autonomous: true
+requirements: [FED-07]
+gap_closure: true
+
+must_haves:
+ truths:
+ - "A fresh Fedimint gateway install derives its admin credential from the per-install secret the manifest declares, so two nodes installed from the same image never share a gateway password (FED-07)"
+ - "No code path configures a gateway container with a credential literal carried in this repository — a missing or unreadable gateway secret makes the install fail loudly instead of quietly starting with a shipped default (FED-07 failure-surfacing)"
+ - "The compromised default hash exists in exactly one place in the tree, as a detection denylist that is never used to configure a container"
+ - "The gateway credential lives under one canonical secret name across the Rust orchestrator, first-boot, reconcile, and both deploy scripts — a node can no longer end up with the daemon reading one file while the scripts wrote another"
+ - "A first boot on a host without htpasswd still produces a unique per-install credential rather than falling back to a shipped one (FED-07 empty edge — the ISO path)"
+ - "Generating the gateway credential twice on the same node is idempotent: the second call leaves the existing value untouched, so a reconcile pass never rotates a working gateway out from under itself (FED-07 adjacency edge)"
+ prohibitions:
+ - statement: "No credential value that grants access to a running service may be committed, printed to a log line, embedded in a container image, or written into an ISO/release artifact — the denylist entry retained for detection is a bcrypt hash of an already-public value and is never passed to a container"
+ category: safety
+ - statement: "Removing the default MUST NOT silently disable the gateway — an install that cannot obtain a per-install credential reports an error naming the missing secret; it never starts an unauthenticated or partially configured gateway instead"
+ category: transparency
+ artifacts:
+ - path: core/archipelago/src/container/secrets.rs
+ provides: "Canonical per-install gateway credential accessor plus the known-default denylist"
+ contains: "KNOWN_DEFAULT_GATEWAY_HASHES"
+ key_links:
+ - from: core/archipelago/src/api/rpc/package/config.rs
+ to: core/archipelago/src/container/secrets.rs
+ via: "the fedimint-gateway spec builder asks container::secrets for the per-install hash and propagates the error instead of substituting a literal"
+ pattern: "gateway_bcrypt_hash"
+ - from: scripts/container-specs.sh
+ to: core/archipelago/src/container/secrets.rs
+ via: "both read the same canonical secret filename, so the shell reconcile path and the daemon agree on one credential"
+ pattern: "fedimint-gateway-hash"
+---
+
+
+Remove every shipped Fedimint gateway credential from the tree and make each install derive its own,
+so two nodes flashed from the same ISO never answer to the same gateway password.
+
+Purpose: FED-07 is a BLOCKER. `apps/fedimint-gateway/manifest.yml` already declares the right thing
+(`generated_secrets: fedimint-gateway-hash, kind: bcrypt`), and `container::secrets` already
+materialises it per install at 0600 — but five code paths bypass that and substitute a hash literal
+committed to this repository when the secret is missing, and one deploy path substitutes a plaintext
+password literal. Anyone with a copy of this repo holds the admin credential for every gateway that
+ever took one of those fallbacks. The repo's own standing invariant already forbids this: "Secrets are
+manifest-declared (`generated_secrets`, materialised by `container::secrets`, 0600/rootless) — never
+hardcoded, per-app, or logged."
+Output: one canonical per-install accessor, five fallback sites removed, a detection-only denylist,
+and tests that fail if a credential literal is ever reintroduced.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/01-federation-mesh-hardening/01-CONTEXT.md
+@apps/fedimint-gateway/manifest.yml
+@core/archipelago/src/container/secrets.rs
+
+
+## Artifacts this phase produces
+
+Created or changed by **this plan**:
+
+| Symbol | Kind | File |
+|---|---|---|
+| `KNOWN_DEFAULT_GATEWAY_HASHES` | detection-only denylist constant | `core/archipelago/src/container/secrets.rs` |
+| `gateway_bcrypt_hash(secrets_dir) -> Result` | canonical per-install accessor | same |
+| `ensure_gateway_credential(secrets_dir) -> Result<()>` | idempotent generator (bcrypt hash + `.pw` sibling) | same |
+| fallback-free `fedimint-gateway` spec arm | changed match arm | `core/archipelago/src/api/rpc/package/config.rs` |
+| fallback-free `configure_fedimint_lnd` | changed function | `core/archipelago/src/api/rpc/package/dependencies.rs` |
+| credential generation without a shipped fallback | changed shell blocks | `scripts/first-boot-containers.sh`, `scripts/reconcile-containers.sh`, `scripts/deploy-to-target.sh`, `scripts/deploy-tailscale.sh` |
+| canonical secret-name read with an empty guard | changed shell block | `scripts/container-specs.sh` |
+
+
+
+
+ Task 1: End-to-end — a gateway spec that cannot be built without a per-install credential
+ core/archipelago/src/container/secrets.rs, core/archipelago/src/api/rpc/package/config.rs, core/archipelago/src/api/rpc/package/dependencies.rs
+
+ - `core/archipelago/src/container/secrets.rs` — the whole file (about 225 lines). Note
+ `ensure_one`'s `SecretGenKind::Bcrypt` arm: it already generates a 24-byte random hex password,
+ bcrypt-hashes it, writes the hash to `` and the plaintext to `.pw`, both 0600 via
+ the atomic `write_secret` helper. Note the idempotent fast path and the self-heal branch. This
+ is the behaviour the new accessor must reuse, not reimplement.
+ - `core/archipelago/src/api/rpc/package/config.rs` lines 596-620 (`read_secret`, which takes a
+ `default: &str` — the mechanism that makes a fallback literal possible) and lines 1051-1084
+ (the `"fedimint-gateway"` match arm inside the app-config table, where the hash is read with a
+ literal default and then passed to `--bcrypt-password-hash`).
+ - `core/archipelago/src/api/rpc/package/dependencies.rs` lines 718-769 (`configure_fedimint_lnd`)
+ — the second site, reading the same secret path directly with `unwrap_or_else` onto the same
+ literal, then rebuilding the whole argv in LND mode.
+ - `core/archipelago/src/api/rpc/package/install.rs` lines 583-606 — how `get_app_config` and
+ `configure_fedimint_lnd` are called during install, so you can see what an error from either
+ has to propagate through.
+ - `apps/fedimint-gateway/manifest.yml` — the `generated_secrets` block already declaring
+ `fedimint-gateway-hash` with `kind: bcrypt`, and the `secret_env` mapping `FEDI_HASH` to it.
+ The manifest is already correct; this task makes the non-manifest paths agree with it.
+
+
+ - `ensure_gateway_credential` on an empty secrets dir writes both the hash file and its `.pw`
+ sibling, each 0600, and the plaintext verifies against the hash.
+ - Called a second time on the same dir it changes nothing — the hash read back is byte-identical.
+ - `gateway_bcrypt_hash` on a dir with no gateway secret returns `Err`, and the error message names
+ the missing secret file so an operator can act on it.
+ - `gateway_bcrypt_hash` on a dir whose stored hash is a known-default denylist entry returns `Err`
+ rather than handing the compromised value back to a caller.
+ - Two successive fresh generations in two different temp dirs produce two different hashes — the
+ value is per install, not per build.
+
+
+ Write the tests in `secrets.rs`'s existing `mod tests` first and confirm they fail.
+
+ In `core/archipelago/src/container/secrets.rs` add three items.
+
+ First, a private denylist constant `KNOWN_DEFAULT_GATEWAY_HASHES: &[&str]` holding the single
+ bcrypt hash currently used as a fallback at `config.rs:1054` (copy it from there verbatim). Give
+ it a doc comment saying it exists only so an install carrying it can be detected and rotated, that
+ it must never be handed to a container, and that plan 01-16 consumes it for the migration. This is
+ the one and only place that value may appear in the tree after this plan.
+
+ Second, `pub fn ensure_gateway_credential(secrets_dir: &Path) -> Result<()>` — a thin wrapper that
+ reuses the existing bcrypt generation path for the `fedimint-gateway-hash` name rather than
+ duplicating it. Factor the `SecretGenKind::Bcrypt` arm of `ensure_one` into a small helper both
+ call so there is exactly one bcrypt-generation implementation; keep `ensure_one`'s existing
+ idempotent fast path and self-heal semantics intact so callers on a reconcile tick never rotate a
+ working credential.
+
+ Third, `pub fn gateway_bcrypt_hash(secrets_dir: &Path) -> Result` — reads the canonical
+ hash file, trims it, and returns `Err` with a message naming the file path when it is missing,
+ empty, or unreadable. Before returning Ok, compare the trimmed value against the denylist and
+ return `Err` if it matches, with a message saying the install is carrying a publicly known default
+ and pointing at the rotation path.
+
+ In `config.rs`: change the `"fedimint-gateway"` arm to obtain its hash from
+ `container::secrets::gateway_bcrypt_hash`, calling `ensure_gateway_credential` first so a fresh
+ node self-provisions. Because `get_app_config` returns a tuple rather than a `Result`, do not
+ silently swallow the error — surface it the way the surrounding code surfaces other hard install
+ failures (an `Err` return threaded to the caller if the signature already allows it, otherwise a
+ logged error plus an argv the install path rejects; whichever you choose, an install with no
+ credential must not reach `podman run`). Record the choice and its reason in the SUMMARY. Delete
+ the `default` parameter from `read_secret` if no other caller needs it; if other callers do, leave
+ the helper alone and simply stop routing the gateway through it.
+
+ In `dependencies.rs`: `configure_fedimint_lnd` must take the already-resolved hash as a parameter
+ from its caller rather than re-reading the file with its own fallback, so there is one read site
+ and one failure point. Update the `install.rs` call accordingly.
+
+ Do not change the gateway's ports, volumes, data directory, network, capabilities, health check,
+ or any non-credential argv element. This task changes where the credential comes from, nothing
+ else about how the gateway runs.
+
+
+ cd core && cargo test -p archipelago secrets 2>&1 | tail -20
+
+
+ - `cd core && cargo test -p archipelago secrets` exits 0 and its output names at least five test cases covering: fresh generation, idempotence, missing-secret error, denylisted-value error, and two dirs producing two different values.
+ - `grep -rl 't9YjjxkiktrlYvjajB' --include='*.rs' core/ | wc -l` equals 1, and that one file is `core/archipelago/src/container/secrets.rs`.
+ - `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/config.rs` equals 0.
+ - `grep -c 't9YjjxkiktrlYvjajB' core/archipelago/src/api/rpc/package/dependencies.rs` equals 0.
+ - `grep -v '^\s*//' core/archipelago/src/api/rpc/package/config.rs | grep -c 'gateway_bcrypt_hash'` is at least 1.
+ - `grep -v '^\s*//' core/archipelago/src/container/secrets.rs | grep -c 'KNOWN_DEFAULT_GATEWAY_HASHES'` is at least 2 (the definition and its use in the accessor).
+ - `cd core && cargo build -p archipelago` exits 0.
+ - `cd core && cargo test -p archipelago` exits 0 — no existing suite regressed.
+ - The SUMMARY records how a credential-less install is made to fail and why that mechanism was chosen.
+
+ The Rust orchestrator can only configure a gateway with a per-install credential; the compromised literal survives in exactly one detection-only location.
+
+
+
+ Task 2: The shell install paths generate their own credential instead of shipping one
+ scripts/first-boot-containers.sh, scripts/reconcile-containers.sh, scripts/deploy-to-target.sh, scripts/deploy-tailscale.sh, scripts/container-specs.sh
+ `openssl` is on PATH on this machine (the scripts already rely on it for the other per-install database passwords, so the replacement generator introduces no new host dependency)
+
+ - `scripts/first-boot-containers.sh` lines 390-426 — the per-install password loop for
+ mempool/btcpay/mysql-root (the correct pattern: `openssl rand`, write, chmod 600), then the
+ gateway block immediately below it that writes `fedimint-gateway-password`, tries `htpasswd` for
+ the hash, and on a host without `htpasswd` logs a warning and assigns the shipped literal. This
+ is the ISO first-boot path, so this is the site that put the default on real nodes.
+ - `scripts/reconcile-containers.sh` lines 690-710 — the same generate-or-skip block, with the same
+ `htpasswd` dependency and the same two-file naming.
+ - `scripts/deploy-to-target.sh` lines 1224-1262 — the remote generation block, the
+ `FEDI_HASH=` export read back over SSH, and the literal fallback when the read comes back empty.
+ - `scripts/deploy-tailscale.sh` lines 494-513 (generation plus the same literal fallback) and lines
+ 770-793 (the container-creation block, where a plaintext password fallback is substituted when
+ the password file cannot be read, and where the argv uses a plaintext password flag rather than
+ the hash flag every other path uses).
+ - `scripts/container-specs.sh` lines 60-72 — the shared spec loader, which reads
+ `fedimint-gateway-hash` (correct name) and escapes `$` so the bcrypt hash survives the
+ `eval` in `reconcile-containers.sh`'s `build_run_cmd`. Preserve that escaping.
+
+
+ Replace the htpasswd-or-fallback pattern everywhere with generation that has no fallback.
+
+ In `first-boot-containers.sh` and `reconcile-containers.sh`: keep generating the plaintext with
+ `openssl rand`, but when `htpasswd` is unavailable do NOT assign a shipped value. Either compute
+ the bcrypt hash without `htpasswd` (openssl's `passwd` applet does not emit bcrypt, so if you go
+ this route use a hasher the host actually has — verify what is present on a node before choosing)
+ or, if no local hasher exists, leave the hash file absent and let the daemon's
+ `ensure_gateway_credential` from Task 1 materialise it on the next reconcile tick. The second
+ option is preferred: it removes the host dependency entirely and puts generation on the one
+ canonical path. In that case the script must log that the gateway credential will be generated by
+ the daemon, and must not create a half-provisioned pair of files.
+
+ Unify the naming. The manifest and the daemon use `fedimint-gateway-hash` for the hash and
+ `fedimint-gateway-hash.pw` for the plaintext; the scripts use `fedimint-gateway-password` for the
+ plaintext. Converge on the manifest's names. Where a script currently writes
+ `fedimint-gateway-password`, have it write the `.pw` sibling name instead, and — because
+ migrations never destroy data — if the legacy file exists and the new one does not, copy the value
+ across (preserving 0600) rather than regenerating, so a node that already has a working unique
+ credential keeps it. Never delete the legacy file in this plan; plan 01-16 owns retirement.
+
+ In `deploy-to-target.sh` and `deploy-tailscale.sh`: when the hash read back from the target comes
+ back empty, abort that step with a clear message instead of substituting the literal. A deploy that
+ cannot read the target's credential must not create a gateway container. In
+ `deploy-tailscale.sh`'s container-creation block, remove the plaintext-password fallback on line
+ 777 entirely and switch that argv to the same hash flag every other path uses, sourced from the
+ same secret; if the hash is unavailable, skip creating the gateway container and print why.
+
+ In `container-specs.sh`: leave the secret name as-is (it is already canonical) and leave the `$`
+ escaping intact; only add the empty-value guard so a missing hash produces a skipped spec with a
+ message rather than an empty hash argument.
+
+ Every changed script must stay `sh`-compatible where it already is and must pass `bash -n`.
+
+
+ for f in scripts/first-boot-containers.sh scripts/reconcile-containers.sh scripts/deploy-to-target.sh scripts/deploy-tailscale.sh scripts/container-specs.sh; do bash -n "$f" || exit 1; done; test "$(grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l)" -eq 0
+
+
+ - `bash -n` exits 0 for all five scripts.
+ - `grep -rl 't9YjjxkiktrlYvjajB' --include='*.sh' scripts/ | wc -l` equals 0.
+ - `grep -c "|| echo 'archipelago'" scripts/deploy-tailscale.sh` equals 0.
+ - `grep -c -- '--password ' scripts/deploy-tailscale.sh` equals 0 — the gateway argv uses the hash flag, like every other path.
+ - `grep -rl 't9YjjxkiktrlYvjajB' . --include='*.rs' --include='*.sh' --include='*.yml' --include='*.json' --include='*.md' | wc -l` equals 1 (only the Task 1 denylist).
+ - `grep -v '^\s*#' scripts/first-boot-containers.sh | grep -c 'htpasswd'` is 0, or the SUMMARY records which hasher replaced it and that it is present on a node.
+ - `cd core && cargo test -p archipelago` exits 0.
+ - The SUMMARY records, for each of the five scripts, what the no-credential path now does, and confirms the legacy plaintext filename is copied forward rather than regenerated when present.
+
+ No script in the tree can configure a gateway with a credential that shipped with the repo; a node with no credential gets one generated for it or is told why the gateway was skipped.
+
+
+
+
+## Planner Assumptions (flagged, unresolved)
+
+- **Whether the compromised hash's plaintext is publicly recoverable:** the planner did not run
+ `bcrypt::verify` against candidate plaintexts. The severity of FED-07 does not depend on it (a
+ shipped hash is a shipped credential regardless), but the migration in plan 01-16 phrases its
+ operator message differently if the plaintext is a guessable word. Task 1's tests are the natural
+ place to settle it; record the finding in the SUMMARY either way.
+- **Whether `get_app_config`'s signature can return `Result` without a wide refactor:** the planner
+ read the call site but not every arm of the table. Task 1 explicitly allows either mechanism and
+ requires the choice to be recorded, so this is a bounded implementation decision, not a scope gap.
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| repository → running node | Anything committed here reaches every node and every reader of the mirror |
+| gateway admin API (`0.0.0.0:8176`) → network | The credential this plan governs is the only thing gating Lightning gateway administration |
+| deploy host → target node over SSH | Credentials are read back across this boundary by two deploy scripts |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
+|-----------|----------|-----------|----------|-------------|-----------------|
+| T-01-50 | Elevation of Privilege | shipped default credential granting gateway admin on any node that took a fallback | critical | mitigate | Both tasks delete every configure-time fallback; the repo-wide grep acceptance criterion fails the task if any credential literal survives outside the detection denylist |
+| T-01-51 | Spoofing | an attacker authenticating to a node's gateway with the publicly known default | critical | mitigate | `gateway_bcrypt_hash` refuses to return a denylisted value, so a node carrying it cannot be reconfigured with it even by this codebase |
+| T-01-52 | Information Disclosure | the generated plaintext leaking through a log line or a deploy transcript | high | mitigate | Generation reuses `write_secret` (0600, atomic, never logged); the scripts are changed to log only that generation happened, never the value; the acceptance criteria forbid printing it |
+| T-01-53 | Denial of Service | removing the fallback bricking installs on hosts without a bcrypt hasher | medium | mitigate | Task 2's preferred branch removes the host-tool dependency entirely by deferring to the daemon's own generator, and requires the skip path to print a reason rather than fail silently |
+| T-01-54 | Tampering | a half-written credential pair leaving a gateway configured against a hash whose plaintext nobody holds | medium | mitigate | Generation reuses the existing atomic temp-file-plus-rename `write_secret` and its self-heal branch; Task 2 forbids creating a half-provisioned pair |
+| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | This plan installs no packages — it edits existing Rust and shell only. If an implementation choice would add a crate, stop and raise it: RESEARCH.md's Package Legitimacy Audit must cover it first, with a blocking human checkpoint for any `[ASSUMED]`/`[SUS]` entry |
+
+
+
+- `cd core && cargo test -p archipelago` — green.
+- `cd core && cargo build -p archipelago` — green.
+- `bash -n` clean on all five changed scripts.
+- Repo-wide: exactly one occurrence of the compromised hash, in the detection denylist.
+
+
+
+- The gateway credential comes from `container::secrets` on every path — daemon, first boot, reconcile, and both deploys.
+- No credential literal in the tree configures anything; the one retained copy exists solely to detect and reject.
+- A node with no credential gets one generated, or is told clearly why the gateway was not created.
+- One canonical secret filename, with the legacy plaintext value carried forward rather than regenerated.
+
+
+
diff --git a/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md b/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md
new file mode 100644
index 00000000..5bdb03c1
--- /dev/null
+++ b/.planning/phases/01-federation-mesh-hardening/01-12-PLAN.md
@@ -0,0 +1,235 @@
+---
+phase: 01-federation-mesh-hardening
+plan: 12
+type: execute
+wave: 7
+depends_on: []
+files_modified:
+ - neode-ui/src/views/web5/Web5ConnectedNodes.vue
+ - neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
+autonomous: true
+requirements: [UIFIX-02]
+gap_closure: true
+
+must_haves:
+ truths:
+ - "On a wide viewport the connected-nodes card's height is set by its row sibling, not by how many nodes are in the list — adding nodes makes the inner list scroll instead of making the row taller (UIFIX-02)"
+ - "The inner list scrolls within the matched height: with more rows than fit, a scrollbar appears inside the card and the card stays put"
+ - "With a short sibling the card still has a usable list height rather than collapsing to its header and tabs (UIFIX-02 empty edge, sibling half)"
+ - "With zero connected nodes the card renders its existing empty/loading row and does not collapse (UIFIX-02 empty edge, list half)"
+ - "All three tabs — trusted, observers, requests — share the same scroll behaviour, so switching tabs never changes the card's height (UIFIX-02 adjacency edge)"
+ - "The stacked single-column layout below the row breakpoint is unchanged: the list keeps its existing capped height and its existing scroll"
+ prohibitions:
+ - statement: "Nothing outside the connected-nodes card's own height and overflow behaviour may change — the card's glass styling, padding, header, tab strip, row markup, counts, and every animation stay byte-identical, and no sibling card in any Web5 row is restyled to make the fix work"
+ category: safety
+ artifacts:
+ - path: neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
+ provides: "Structural pin on the scroll contract for all three tab panes"
+ min_lines: 30
+ key_links:
+ - from: neode-ui/src/views/web5/Web5ConnectedNodes.vue
+ to: neode-ui/src/views/web5/Web5.vue
+ via: "the card is a min-height-zero flex column whose scroll pane contributes no intrinsic height at the row breakpoint, so the grid row is sized by the sibling and stretch gives the card that height"
+ pattern: "overflow-y-auto"
+---
+
+
+Make the connected-nodes list obey the row: its height tracks the taller sibling beside it and the
+list scrolls inside that height, instead of growing until every node fits.
+
+Purpose: UIFIX-02 is a BLOCKER, and it is a regression of an earlier request ("was still meant to
+scroll"). Quick task 260729-je5 made the list fill the card's height; what is missing is the other
+half — the list must not *drive* the card's height. Today all three tab panes carry
+`max-h-72 xl:max-h-none`, so at the `xl` breakpoint where the row becomes two columns the cap is
+lifted and nothing bounds the list: it grows to fit every row, stretches the grid row, and the
+scrollbar the user expects never appears.
+Output: a bounded, sibling-matched card with an internal scroll at the row breakpoint, an unchanged
+stacked layout below it, and a test that pins the contract so a future cleanup cannot undo it again.
+
+
+
+@$HOME/.claude/gsd-core/workflows/execute-plan.md
+@$HOME/.claude/gsd-core/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/STATE.md
+@.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md
+@neode-ui/src/views/web5/Web5.vue
+
+
+## Artifacts this phase produces
+
+Created or changed by **this plan**:
+
+| Symbol | Kind | File |
+|---|---|---|
+| scroll-contract classes on the three tab panes | changed template classes | `neode-ui/src/views/web5/Web5ConnectedNodes.vue` |
+| row-breakpoint height floor on the card root | changed template classes | same |
+| `neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` | new vitest suite | new file |
+
+
+
+
+ Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height
+ neode-ui/src/views/web5/Web5ConnectedNodes.vue, neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
+
+ - `neode-ui/src/views/web5/Web5ConnectedNodes.vue` lines 1-135: the card root
+ (`glass-card p-6 scroll-mt-24 flex flex-col`), the desktop and mobile header blocks, the four-tab
+ strip, and the three `v-show` tab panes at lines 57, 90 and 120 — all three currently carrying
+ `space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`. Also read the loading and
+ empty rows inside the trusted pane so you know what renders when the list is empty.
+ - `neode-ui/src/views/web5/Web5.vue` lines 57-74: the three `grid grid-cols-1 xl:grid-cols-2 gap-6`
+ rows. The connected-nodes card is the left item of the first row and `Web5NodeVisibility` is its
+ right sibling. Confirm no `items-start`/`self-start` is applied anywhere on that row — grid's
+ default `align-items: stretch` is what makes the sibling-matched height work, and this plan must
+ not add or remove alignment utilities on the row.
+ - `neode-ui/src/views/web5/Web5NodeVisibility.vue` — read only far enough to see roughly how tall
+ it renders (it is the sibling whose height the card must adopt). Do not modify it.
+ - `neode-ui/src/views/dashboard/__tests__/keepAliveTabs.test.ts` — the house convention for a
+ structural DOM/class pin test in this repo (this is the file the standing rule names as
+ must-stay-green; read it for its mounting and assertion style, do not change it).
+
+
+ - Mounting the component and reading the trusted pane's class list: it has `overflow-y-auto`, has
+ `min-h-0`, and has no class that removes its height bound at the row breakpoint.
+ - The same three assertions hold for the observers pane and the requests pane.
+ - The pane keeps a capped height below the row breakpoint (the stacked layout is unchanged).
+ - The card root is a flex column with a height floor at the row breakpoint, so a short sibling
+ cannot collapse the list area.
+ - With an empty node list the pane still renders (the existing empty/loading row is present) and
+ the pane element is still in the tree.
+
+
+ Write the test file first and confirm it fails.
+
+ In `Web5ConnectedNodes.vue`, change only the height/overflow contract:
+
+ On each of the three tab panes, replace the current sizing classes so that below the row
+ breakpoint nothing changes (keep the existing capped height and `overflow-y-auto`, keep basis
+ `auto` so the auto-height stacked column still sizes to content), and at the row breakpoint the
+ pane becomes a zero-basis growing flex child with no height cap — `xl:flex-1 xl:basis-0
+ xl:max-h-none` alongside the existing `min-h-0 overflow-y-auto`. Zero basis is the whole trick:
+ it makes the pane contribute nothing to the card's intrinsic height, so the grid row is sized by
+ the sibling alone, `align-items: stretch` gives the card that row height, and `flex-1` then hands
+ the leftover height to the pane, which scrolls inside it.
+
+ On the card root, keep `glass-card p-6 scroll-mt-24 flex flex-col` exactly as it is and add
+ `min-h-0` plus a row-breakpoint height floor (`xl:min-h-[20rem]`) so a sibling shorter than the
+ header-plus-tabs block still leaves a usable, scrolling list area rather than a collapsed strip.
+ Choose the floor to sit close to today's stacked cap so the visual weight of the card is familiar.
+
+ Change nothing else. Do not touch the header blocks, the tab strip, the per-row markup, the count
+ badges, the pulse dot on the requests tab, any `v-show`/`v-if` condition, any script logic, or any
+ class on `Web5.vue`'s grid rows. Do not add a scrollbar style — the list already scrolls with the
+ house default below the breakpoint and must look identical above it.
+
+
+ cd neode-ui && test -f src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts
+
+
+ - The test file exists and `cd neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts` exits 0 (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would pass vacuously).
+ - `grep -c 'xl:max-h-none' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 and each of those three lines also matches `xl:basis-0`.
+ - `grep -c 'flex-auto' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 0.
+ - `grep -c 'max-h-72' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 3 — the stacked cap is untouched.
+ - `grep -c 'xl:min-h-' neode-ui/src/views/web5/Web5ConnectedNodes.vue` equals 1.
+ - `git diff --stat -- neode-ui/src/views/web5/Web5.vue` reports no change.
+ - `git diff -- neode-ui/src/views/web5/Web5ConnectedNodes.vue | grep -c '^[-+].*