Commit Graph
842 Commits
Author SHA1 Message Date
archipelagoandClaude Fable 5 ee9dde9936 fix(mesh-ui): rnodePlan computed for the setup modal (strict TS)
Demo images / Build & push demo images (push) Failing after 4m12s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:35:55 -04:00
archipelagoandClaude Fable 5 4773b32a76 feat(mesh-ui): region-recommended RNode plan applies from the setup modal too
Demo images / Build & push demo images (push) Successful in 4m4s
The device-detected modal's region selector now drives real RNode
settings instead of a "managed by the daemon config" shrug: choosing a
region shows its concrete plan (frequency/bw/SF/CR/power) and Apply &
Connect writes it through mesh.rnode-config-apply — the same
radio-confirmed round-trip as the Device panel, best-effort so a plan
failure never aborts the connect. RNODE_REGION_PLANS moves to
utils/loraRegions (single source shared by panel + modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:31:01 -04:00
archipelagoandClaude Fable 5 f394c02055 fix(mesh): ride out the radio restart during apply — no false errors, no setup modal
Demo images / Build & push demo images (push) Successful in 3m54s
Applying RF settings deliberately restarts the radio daemon (~15-20s).
Two things treated that healthy, expected gap as a fault (operator,
2026-08-06):

- radio_state was single-shot: a query landing inside the restart
  window reported "The radio daemon did not answer the state query"
  for a restart that was working correctly. It now retries for ~30s
  and says the radio is restarting while it waits. A real device-level
  refusal (not an RNode) still returns immediately.
- The device-setup modal auto-opens for any detected-but-unconnected
  port, so the restart looked like a newly plugged stick and
  interrupted the apply. Apply and Reboot now suppress auto-detect for
  90s via mesh.suppressDeviceDetect().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:55:52 -04:00
archipelagoandClaude Fable 5 3cef1d09f4 feat(mesh-ui): RNode settings editor with live device read-back + region presets
Demo images / Build & push demo images (push) Successful in 3m49s
The LoRa device panel's Reticulum section (operator .126 top priority):

- Shows the device's CURRENT settings first — the radio-confirmed r_*
  values from mesh.rnode-config (online badge, port, frequency, bw,
  SF, CR, txpower, airtime limits), with a Refresh action.
- Every RNodeInterface parameter is editable: enabled, serial port
  (auto-detect when blank), frequency, bandwidth (RNode's discrete
  set), SF 5-12, CR 4/5-4/8, txpower, airtime short/long %.
- "Set recommended for <region>" fills the fields from per-region
  plans (EU868 = the operator-validated Portugal plan incl. 25%/10%
  duty-cycle locks); driven by the existing region selector above.
- Apply & Confirm on Device: persists, restarts the radio daemon, and
  reports the radio's own confirmation (green ✓ only when the device
  read-back matches; amber/red messages say what actually happened).
- Action buttons stack in a column (operator layout request).
- Reboot Radio surfaces the backend's real acknowledgement message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:08:19 -04:00
archipelagoandClaude Fable 5 45fa8b6c6f feat(neode-ui): seed & entropy explainer page at /entropy/ + link from Backup settings
Demo images / Build & push demo images (push) Successful in 4m19s
Standalone static guide (same pattern as /architecture/) covering how the
master seed entropy is drawn (explicit OsRng, sealed KeyGenRng allowlist,
degenerate-draw refusal, CSPRNG readiness ledger), how it is stored
(Argon2 + ChaCha20-Poly1305 envelope), the full derivation tree (HKDF
labels, NIP-06, LND aezeed one-way gate, second-order keys), what is NOT
seed-derived, every failure/fallback path, and the restore flow — in
paired layman/technical language. Linked from the Recovery-phrase card
in Settings → Backup. CSP-safe: no inline scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:56:34 -04:00
archipelagoandClaude Fable 5 c972419840 fix(wallet): blank send/receive on every open; sweep shows amount; camera option stays visible
Demo images / Build & push demo images (push) Successful in 3m31s
Operator-reported (2026-08-05):

- Send/Receive modals reset to a blank slate on every open. Stale state —
  destination, amount, memo, and above all an armed "send all funds"
  toggle — silently carried into the next payment.
- Arming "send all funds" now shows the swept balance in the (disabled)
  amount field instead of a confusing 0; disarming or leaving the
  on-chain tab clears it.
- The scan modal no longer hides "Scan with camera" on plain-http desktop
  (browsers only allow getUserMedia on secure origins): the option stays
  visible with a one-line explanation, and choosing it surfaces the HTTPS
  requirement with photo/paste fallbacks. The companion app's native
  scanner path is untouched and still takes priority.
- What's New entry for v1.7.125-alpha.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 21:22:42 -04:00
archipelagoandClaude Fable 5 e7592dc9c9 fix(fedimint): stop declaring 8175 — it belongs to the UI companion, not fedimintd
Demo images / Build & push demo images (push) Successful in 3m32s
Declaring the Guardian UI port on the fedimint app made the orchestrator try
to publish 8175 from fedimintd, colliding with archy-fedimint-ui which
already holds it: start_container failed on every reconcile and fedimint
crash-looped (100.82.34.38). The companion's nginx pinned to 127.0.0.1 is
what actually closes that port; the gate reports it rather than fronting it.

Also: app-login page uses the sidebar's 'A' mark instead of the full
wordmark, is pinned to the small viewport so it stays centred and the
keyboard overlays rather than scrolls it, and the install-version modal
icon uses object-contain so a non-square icon is no longer cropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:12:36 -04:00
archipelagoandClaude Fable 5 d5ca612e2b chore(catalog): sync catalogs to the manifests for 1.7.124
Demo images / Build & push demo images (push) Successful in 3m42s
Portainer's image reaches the public catalog (the release gate caught the
manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's
port list now that it declares a port — it is auth: gated, so the relay
withholds it rather than bridging it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:19 -04:00
archipelagoandClaude Fable 5 cc709fd7da docs(1.7.124): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m46s
Leads with the update that switched nodes off and left them unable to
switch back on — the one an operator most needs to understand, and the
reason to take this release promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:58:28 -04:00
archipelagoandClaude Fable 5 9dd02359e4 feat(settings): session timeout is configurable from the UI
Demo images / Build & push demo images (push) Successful in 3m43s
auth.session-policy.get/set plus a card under Account. Presented as two
plain questions rather than the token mechanism underneath, because the
distinction that matters to an operator is which control actually ends a
session: the dashboard polls constantly, so an idle timeout alone never
fires on an open tab — the absolute cap is what guarantees it.

Values are clamped server-side and the stored result is echoed back, so
the bounds are discoverable instead of an error. Presets rather than a free
number field: a box accepting '5' invites locking yourself out. A short
idle choice warns that it is the payments-industry posture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 14:29:17 -04:00
archipelagoandClaude Fable 5 d8647f6576 fix(mesh): tabbed tools column on very wide screens; add configurable session policy
Demo images / Build & push demo images (push) Successful in 3m36s
Mesh right panel: a >=2560px screen hid the tab bar and stacked all five
tool panels in fixed grid rows. On a real display that clipped the Bitcoin,
Dead Man and AI headings to a few pixels each, letterboxed the map, and
pushed Radio Settings into a scroll — more screen producing a worse view.
Very wide now uses the same tabbed column as every other desktop width,
with the selected panel filling the column and the map running edge to edge
(it is the one panel with nothing to scroll).

Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag,
persisted and clamped. Two tokens already existed — a session token and a
30-day login token — so the knob changes how long a quiet tab stays usable
without putting a long-lived credential on every request. Kiosk screens are
exempt from the idle timeout (nobody is there to log a TV back in) but keep
the absolute cap so a stolen box does not stay authenticated forever. The
cap is not optional theatre: idle alone never fires on a polling dashboard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 13:03:31 -04:00
archipelagoandClaude Fable 5 27c1b151f8 docs(1.7.123): curate release notes and add the in-app What's New block
Demo images / Build & push demo images (push) Successful in 3m59s
Leads with the honest version: five screens were open and the previous
release's own audit reported them as fine, found by scanning from another
machine rather than asking the node. States plainly that what leaked was
the page, not credentials — the macaroon path was verified, not assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 09:08:07 -04:00
archipelagoandClaude Fable 5 c35e33d0a7 docs(1.7.122): curate release notes and add the in-app What's New block
Leads with what changes for the operator: app screens now require the node
password across LAN, Tailscale, mesh and Tor; the wallet/protocol ports that
must stay open stayed open; the mesh leak found during on-node verification;
nodes repairing their own legacy containers; and the signing-key rotation.
Known gaps disclosed, including the eleven still-undeclared ports and that
non-browser clients will now meet the login page.

The new block uses <strong> rather than the literal ** markers in earlier
entries, which render as asterisks in the modal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:03 -04:00
archipelagoandClaude Opus 5 e20d7a14fb docs(whats-new): add the v1.7.121-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m41s
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 01:22:16 -04:00
archipelagoandClaude Opus 5 63d0183dd2 fix(ui): stop the dashboard cards leaving a backdrop-filter seam
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.

Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.

style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.

Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.

The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.

Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:45:32 -04:00
archipelagoandClaude Opus 5 24ce8b39e8 feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.

Both entry points are covered:

- `federation.invite` gates on the RESOLVED level, not on an explicit
  request for Trusted: "Link Your Nodes" sends no `trust_level` at all
  and falls through to the Trusted default. The invite is a bearer grant
  of Trusted to whoever redeems it, so minting it IS the escalation.
  Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
  Trusted, so the dropdown re-emitting its own value doesn't demand a
  password for a no-op.

Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.

The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.

The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.

Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.

Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 13:07:21 -04:00
archipelagoandClaude Opus 5 6a617f3a6c docs(whats-new): add the v1.7.120-alpha block to the in-app modal
Demo images / Build & push demo images (push) Successful in 3m45s
Generated by scripts/sync-whats-new.py, which the release gate checks.
Without it the Settings > What's New modal would have skipped straight
from v1.7.119 to v1.7.121 — the release notes users actually read, as
opposed to CHANGELOG.md which they do not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 01:36:37 -04:00
archipelagoandClaude Opus 5 a5b923fa0b fix(ui): teleported nav must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m30s
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.

Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.

Two offenders, matching the report exactly:

- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
  <body>, gated only on `mobileShowChat` — never on whether Mesh was the
  screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
  NO gate at all, so it leaked out of every view that uses it. Fixing the
  shared component fixes every caller at once: Vue propagates
  activated/deactivated from the KeepAlive boundary down through the
  subtree, so a child can guard itself.

BaseModal already solved the transient-dialog half of this class in
204d4523 by closing on route change. That is the right fix for a dialog
and the wrong one for chrome: a tab bar has no "closed" state to fall
back to, and forcing one would lose the user's place. New
useViewActive() composable instead — chrome is simply not rendered while
its owner is off screen, and returns exactly as it was.

THE PERFORMANCE IS NOT SACRIFICED, which was the explicit constraint.
The Teleport is gated, not the view, so the instance stays cached and
revisiting a tab is still instant. A test pins this: setup() must run
exactly ONCE across a navigate-away-and-back round trip. If someone
later "fixes" this by dropping KeepAlive, that test fails.

Deliberately untouched: AppSession.vue, whose teleport is load-bearing —
its own comment records that moving the iframe node reloads the app, and
app-session is excluded from KeepAlive anyway so it cannot leak. Toasts,
the app launcher and the connection banner are app-level rather than
view-owned; gating those would be wrong.

Verified: 3 new tests; full suite 105 files / 848 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:55:42 -04:00
archipelagoandClaude Opus 5 454388226c feat(01-05): surface federation sync failures to the operator (FED-02)
A failed federation sync existed only as a `debug!` line on the node, so a
peer that had not synced in days looked identical in the UI to one that
synced a minute ago. Now the failure is persisted per peer and rendered.

- `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side
  mirror of the existing `last_transport` / `last_transport_at` pair.
- `federation::record_sync_result(data_dir, did, outcome)` — records the
  message on `Err`, CLEARS both fields on `Ok` so the badge disappears when
  the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner`
  load/save convention established by plan 01-01. An unknown DID is a silent
  Ok that writes nothing, so a peer removed mid-pass is never resurrected by
  an in-flight sync's error write. Skips the save entirely when nothing
  changed, keeping the steady state read-only rather than rewriting
  nodes.json (and contending for the lock) every 90s.
- Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not
  bytes so truncation cannot split a UTF-8 sequence (T-01-18).
- The 90s auto-sync loop calls it on both arms; the existing `debug!` line
  is kept — persisting is additive, not a replacement for logs.
- `federation.list-nodes` emits both fields when set, omits them when unset.
- NodeList renders a red SYNC badge beside the transport badge on both the
  trusted-node and peer rows, message + age in the `title` so the row stays
  single-line.

Tests (written first, confirmed failing — 16 compile errors, E0425 on
`record_sync_result` and E0609 on `last_sync_error`):
- persists_error / success_clears_error / missing_did_is_noop /
  on_empty_store_is_noop / truncates_long_error
- NodeList: badge present when set, ABSENT when unset (the guard against a
  badge that always renders), and present on an observer peer row.

cargo test -p archipelago federation — 42 passed, 0 failed.
vitest NodeList.test.ts — 4 passed. npm run build — green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 11:12:07 -04:00
archipelagoandClaude Opus 5 8255b69af2 fix(01-17): pin the FIPS/Tor pills and let the peer-card badge row wrap (UIFIX-01)
Audited every transport-pill render site in the cloud surfaces at 390x740
and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer
cards, PeerFiles.vue header) and both already appear on a phone; three
file-level sites carry none, by decision recorded in the SUMMARY.

- Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport
  badge. Measured at 320px, a longer trust label squeezed the badge until
  its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a
  second line intact. Inert whenever the row fits, so desktop is unchanged.
- New TransportPills.test.ts: one site-specific assertion per render site,
  so removing a pill fails the build. Dorian asked that these never be
  removed in a future cleanup; nothing in the repo pinned them before.
- Unknown-transport cases assert no pill is fabricated (T-01-78), and the
  labels/colours are asserted against PeerFiles.vue's canonical mapping
  rather than a duplicated table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 10:03:26 -04:00
archipelagoandClaude Opus 5 204d4523da fix(ui): a modal must not outlive the screen that raised it
Demo images / Build & push demo images (push) Successful in 3m20s
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the
wallet's send/receive modal floating over the destination. The Lightning modal
itself did close — the parent did not. Tab views are KeepAlive'd, so
navigating deactivates the owner rather than unmounting it, and its Teleported
modal keeps rendering.

BaseModal now emits close on any route change while shown, fixing the class in
one place rather than per button. Every modal here is a transient dialog; none
should survive navigation. Two tests pin it, including that a hidden modal
stays quiet.

Also fixes a test-only regression from fa26c5fc: useLightningRequired()
resolved the Pinia store at composable-call time, so merely having the gate in
SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts
it bare). The store is now resolved lazily inside the function that needs it —
a gate should never be what breaks a component's ability to mount. That one
shipped because I verified fa26c5fc with targeted tests and a build but had
not re-run the full suite since 5718179e.

Verified: full suite 103 files / 827 tests green; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 08:12:49 -04:00
archipelagoandClaude Opus 5 c3d5bcd271 fix(wallet): gate lightning on CHANNELS, not just node state
Demo images / Build & push demo images (push) Successful in 3m24s
A running LND with zero channels happily mints an invoice — it is simply
unpayable, because nobody has a route in. So the state-only gate let receive
through and handed the user a useless invoice, and let send walk to confirm.
Neither errored, so the funding modal (wired to failures) never fired.

requireLightningReady(direction) now asks lnd.listchannels and checks the
liquidity that actually matters for the attempt: total_inbound to receive,
total_outbound to send. It fails OPEN on an RPC error — a transient blip
should not block a working wallet.

The no-funds mode says plainly that a channel is needed, in the direction's
own terms (inbound vs outbound), and offers both routes: "Open a channel"
straight to the channels screen where the Zeus/Olympus flow is already
prefilled, and "Setup Guide" to the run-lightning-node walkthrough for someone
who wants the whole path explained. Buttons wrap rather than squeeze on
narrow screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:51:08 -04:00
archipelagoandClaude Opus 5 700947ea3c feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Demo images / Build & push demo images (push) Successful in 3m18s
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is
vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than
hotlinked on purpose — these nodes run offline/airgapped, and a remote image
would both break there and leak a request to a third-party host on every
render. A missing asset falls back to a neutral bolt glyph so a row can never
render a broken-image box.

Mobile: the choice row keeps icon + name + blurb together and drops the action
to its own full-width line under 26rem, instead of squeezing the description
into a two-word column next to a button.

Funding mode: a node that is running but has no funds / no inbound liquidity
is neither "install one" nor "start it", so the same modal gains a third mode
that explains it and routes to the run-lightning-node goal, where funding and
channel-opening already live — reusing that flow rather than duplicating it.

It fires where the user actually meets the problem: on a failed attempt.
handleLightningFailure() maps a running node's send/receive failure onto the
funding modal, matched on message text because LND surfaces "no route", "no
channels" and "insufficient balance" as plain strings with no distinct code —
and all three mean the same thing to a user: fund me.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:43:24 -04:00
archipelagoandClaude Opus 5 fa26c5fc56 fix(wallet): gate lightning on node STATE, gate send too, add a shared CopyButton
Three defects from testing the previous commit on archi-dev-box:

1. The gate keyed on `id in packages`, which is not "installed and usable" —
   package-data carries an entry for a Lightning app that is known but not
   running. On a box with no lnd container at all the gate passed and the raw
   error came through as "Operation failed. Check server logs for details."
   Now keyed on PackageState.Running.

2. Because installed-but-stopped is a real and different situation, the modal
   has two modes: absent offers the install choices, stopped says the node
   isn't running and offers "Open My Apps". Neither dead-ends in an error.

3. Lightning SEND let you walk all the way to confirm-send with no node. The
   gate now runs in review(), before the confirm step — failing at submit
   after a review screen is the defect, not a smaller version of it.

Also adds CopyButton, the start of one consistent copy affordance: icon +
label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a
document.execCommand fallback so copy still works over plain http on a LAN IP
(navigator.clipboard rejects on insecure origins, which is how a lot of nodes
are reached). Converted the wallet's own copies — the lightning invoice the
user reported, plus the on-chain/Ark addresses and the payment hash/txid.

20 of 25 copy sites across 15 other files still use ad-hoc markup; converting
them is mechanical but was not attempted here rather than half-done.

Verified: 5 gate tests; npm run build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:36:25 -04:00
archipelagoandClaude Opus 5 5718179e2f feat(wallet): offer to install a Lightning node instead of failing an invoice
Demo images / Build & push demo images (push) Successful in 3m16s
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.

useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.

The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.

Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".

Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.

Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:23:37 -04:00
archipelagoandClaude Opus 5 579398981f feat(01-03): paid tick renders the branded screensaver ring (FED-06)
Demo images / Build & push demo images (push) Failing after 1m14s
Both paid-tick surfaces now show the EQ-segment ring instead of a CSS ripple
burst (send modal) and a plain circle (scan modal), via a new `badge` size
variant at 160px/192px with matching --viz-radius. A transform scale of the
compact variant was ruled out in 01-UI-SPEC.md because it would scale segment
stroke width and blur along with the geometry.

Both surfaces use the identical composition — a badge-sized relative container
with the checkmark core absolutely centred over the ring — so the two ticks
cannot drift apart visually.

ScreensaverRing also gains the prefers-reduced-motion guard it never had, on
the component rather than per call site, so the screensaver and
SystemDangerZone variants are covered too.

Also records live-browser verification for 01-13's scroll cue (three
viewports, plus a geometry probe of the hide condition).

Verified: 5 new tests; full suite 102 files / 822 tests green; npm run build
clean with viz-ring-badge present in the built bundle. The live visual
no-clipping observation Task 2 asks for is explicitly NOT done — recorded as
deferred to plan 01-07's consolidated sign-off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:07:10 -04:00
archipelagoandClaude Opus 5 5f366f7589 fix(01-13): on-brand scroll cue makes the onboarding tickbox findable (UIFIX-03)
Demo images / Build & push demo images (push) Successful in 4m25s
On a short viewport the seed-confirmation tickbox sits below the fold inside
the step's scrolling area while Continue stays pinned and disabled in the
fixed footer — onboarding reads as broken rather than incomplete.

The cue is a sticky-bottom scrim and glass pill inside the scroll region, and
its visibility comes from real geometry: scrollHeight vs clientHeight for
overflow, then a getBoundingClientRect comparison of the tickbox's bottom
against the container's. On a tall screen the element does not render at all,
so those screens are unchanged. Rects rather than offsetTop because offsetTop
is relative to the nearest positioned ancestor — here the outer card, not the
scroll container.

It is wayfinding only: activating it scrolls the tickbox into view and never
sets confirmed, focuses Continue, or auto-ticks, which a test pins.

Listener setup was moved onto both onMounted paths — the sessionStorage
restore path returned early, so a user navigating back would have had no cue.

Verified: 6 new tests plus the full frontend suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:54:40 -04:00
archipelagoandClaude Opus 5 46bf5a7870 fix(01-15): hand the video off to a custodial PiP session (UIFIX-05)
Entering picture-in-picture read as the lightbox being dismissed, and the
session died with it: both Teleport and KeepAlive move their subtree on
deactivation, which the PiP spec treats as removal.

MediaLightbox now listens for the video's own enterpictureinpicture event —
so PiP entered by the browser's native control behaves identically to the
toolbar button — and follows a fixed order: adopt, animate, then emit close.
Adopting first is what makes the element survive the unmount the emit
triggers; the invariant is documented in place so a refactor cannot reorder
it innocently.

The backdrop animates a handoff on the PiP path only, closing on transitionend
with a bounded 350ms fallback for browsers that skip the transition and for
the reduced-motion path where the duration is zero and the event never fires.

Verified: 5 new tests plus the full frontend suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 05:53:38 -04:00
archipelagoandClaude Opus 5 4a8925f09e fix(01-14): satisfy vue-tsc strict-null checks in usePaidItemViewer.test.ts
Demo images / Build & push demo images (push) Successful in 3m41s
npm run build's vue-tsc -b pass caught TS2532 (possibly-undefined array
access) on two array-index reads the vitest run alone doesn't type-check —
optional-chain them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:29:28 -04:00
archipelagoandClaude Opus 5 b5628d968e fix(web5): raise connected-nodes row-breakpoint height floor to 40rem (UIFIX-02)
Demo images / Build & push demo images (push) Has been cancelled
Dorian verified the sibling-height match, internal scroll, and unchanged
stacked layout on his running dev session — all correct. The only issue was
the xl:min-h-[20rem] floor (an unmeasured judgement call, flagged as such in
01-12-PLAN.md): when node discovery is disabled, Web5NodeVisibility renders
short, the floor takes over, and 20rem left the Connected Nodes card looking
stunted. Doubled to xl:min-h-[40rem] per his direct instruction ("twice as
tall"). Test updated to pin the new value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:27:44 -04:00
archipelagoandClaude Opus 5 bc9a210c75 fix(01-14): route Paid Files pictures/videos into the app lightbox with a visible wait (UIFIX-04/UIFIX-06)
Demo images / Build & push demo images (push) Successful in 3m47s
Cloud.vue's viewPaidItem() called window.open() instead of the in-app
MediaLightbox, and its content.owned-get fetch had a 60s timeout with no
loading indicator and a swallowed catch. Moves the fetch/decode/route logic
into a new usePaidItemViewer composable: image/video route to a second
MediaLightbox instance fed a synthetic FileBrowserItem, audio still goes to
the global bottom-bar player, and anything with no in-app viewer keeps
today's browser-tab fallback. The Paid Files row now shows an "Opening…"
spinner (matching PeerFiles' existing treatment) for the fetch's duration,
becomes non-interactive to prevent double-fetch, and a real error surfaces
through the view's existing alert-error block instead of an empty catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:17:57 -04:00
archipelagoandClaude Opus 5 3288a02df8 feat(01-15): singleton PiP session with body-level custodial host
Demo images / Build & push demo images (push) Successful in 3m44s
usePipSession() owns an off-screen div under document.body; adopt(video)
moves the element there so it survives the unmount of whatever view
rendered it (a Teleport and a KeepAlive'd view both move their subtree on
deactivation, which the picture-in-picture spec treats as removal).
release() tears down playback and detaches the element; a
leavepictureinpicture listener on the adopted element is the primary
release path so an orphaned owner can never leak it.

Also adds isPipSupported() to pip.ts — a call-time version of the existing
import-time pipSupported const, needed because a test can't restub
document.pictureInPictureEnabled after import. togglePip and pipSupported
are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:11:56 -04:00
archipelagoandClaude Opus 5 ceafbcb596 fix(web5): bound connected-nodes tab panes to sibling-matched height (UIFIX-02)
Demo images / Build & push demo images (push) Successful in 3m58s
The three tab panes carried max-h-72 xl:max-h-none, so at the xl breakpoint
(where the Web5 row becomes two grid columns) the cap lifted with nothing to
replace it: the visible pane grew to fit every row, stretched the grid row,
and the scrollbar the user expects never appeared.

Give each pane xl:flex-1 xl:basis-0 xl:max-h-none instead — zero flex-basis
means the pane contributes no intrinsic height, so the grid row is sized by
the Web5NodeVisibility sibling alone, grid's default align-items: stretch
gives the card that height, and flex-1 hands the leftover height back to the
pane, which scrolls inside it via the existing overflow-y-auto. The card root
gets min-h-0 (so the flex column can shrink below content height) plus an
xl:min-h-[20rem] floor so a short sibling still leaves a usable list area
instead of collapsing to the header+tabs strip.

Below the row breakpoint nothing changes: the stacked cap (max-h-72) and
scroll are untouched, and dropping flex-auto (replaced by nothing, i.e. the
default 0 1 auto) has no visible effect since single-column stacked cards
have no extra flex space to distribute anyway.

Adds Web5ConnectedNodesScroll.test.ts to pin the contract across all three
tab panes and the card root so a future cleanup cannot reintroduce the
grow-to-fit regression a third time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:38:34 -04:00
archipelagoandClaude Opus 5 258a91781c fix(release): correct v1.7.119-alpha changelog/manifest lifecycle-gate note
create-release-manifest.sh's changelog extraction pulls every non-blank
line between the version header and the next "## ", not just "- "
bullets — so the previous commit's "### Known gap" markdown heading and
its paragraph leaked into releases/manifest.json (and, via
sync-whats-new.py, the Settings "What's New" modal) as a malformed,
truncated entry (the closing clarification sentence was cut by the
extractor's 10-line cap).

Rewritten as a single "- " bullet, matching every other CHANGELOG entry,
so it renders cleanly and completely in both the OTA manifest and the
in-app modal instead of showing raw "### " syntax to node operators.
Also folds in core/Cargo.lock's version bump, which create-release.sh's
own commit step omits from its `git add` list.

Same binary/frontend artifacts as the prior commit (identical sha256/
size in the regenerated manifest) — only the changelog text changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:50:34 -04:00
archipelagoandClaude Opus 5 af1af8266c docs: sync What's New modal for v1.7.119-alpha; drop markdown emphasis from CHANGELOG
scripts/sync-whats-new.py --check (part of the release gate) requires
every CHANGELOG version to have a matching block in the Settings
"What's New" modal. Also strips CHANGELOG markdown bold/italic markup
from the v1.7.119-alpha bullets first — the modal renderer only
strips backticks, not **/* emphasis, so it would have leaked literal
asterisks into the user-facing modal text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:17:29 -04:00
archipelagoandClaude Opus 5 2c25e512a7 fix(02-11): gate three leaked background pollers to activate/deactivate
Demo images / Build & push demo images (push) Has been cancelled
Fleet.vue's useFleetData() (60s telemetry.fleet-status/-alerts poll),
Server.vue's FipsNetworkCard.vue (15s fips.status poll), and Web5.vue's
Web5Monitoring.vue (30s system.stats poll — redundant with Home.vue's
own correctly-gated 10s poll of the same store) all armed their
setInterval in onMounted and only disarmed it in onUnmounted/
onBeforeUnmount. That was harmless before 02-04 registered their
owning views in KEEP_ALIVE_PATHS (the view was destroyed on every
tab-away, so the teardown hook fired every time); once KeepAlive keeps
the instance alive, the teardown hook never fires again and the poll
ran forever in the background regardless of which dashboard tab was
showing.

Gated arm/disarm to onActivated/onDeactivated, mirroring Server.vue's
own vpnPollInterval fix from 02-04 exactly. Added regression tests to
keepAliveLifecycle.test.ts mounting each real component under a
synthetic KeepAlive with fake timers; confirmed RED against the
pre-fix code (git stash) before confirming GREEN with the fix restored.

Full suite (95 files/788 tests), type-check and build all green.
keepAliveTabs.test.ts is byte-for-byte unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:00:32 -04:00
archipelagoandClaude Opus 5 5fc3284a57 feat(pwa): auto-apply service worker updates instead of prompting
Demo images / Build & push demo images (push) Successful in 3m38s
Alpha-stage, user-approved: a prompt only reaches users who click it, so
security fixes sat unapplied in long-lived sessions (installed PWA, kiosk
displays). Extends the existing kiosk-only auto-apply to all non-demo
clients.

Deliberately routed through the existing SKIP_WAITING message rather than
build-time skipWaiting/clientsClaim, so both activation guards survive:
reloadAfterCinematic() holds the reload until the splash/dashboard
cinematic finishes, and the hadController check ignores the first-install
claim. A build-time skipWaiting would bypass both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:04:11 -04:00
archipelagoandClaude Opus 5 b839111571 fix(02-review): audit and set explicit persist on every useCachedResource call site
Demo images / Build & push demo images (push) Successful in 3m36s
Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.

persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
  Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
  cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
  peer-identity case, but a private per-user file listing; chosen false as
  the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
  Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
  persist:true to persist:false — this shared cache key's real fips.status
  response carries npub (this node's own FIPS identity key), which
  Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
  fuller FipsStatus type does; both call sites must agree since a mismatch
  trips the dev-only entry() persist-consistency warning. Found during this
  audit, not part of the originally-scoped call-site list — corrected as a
  same-class T-02-01 violation. serverTabCache.test.ts updated to match.

persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
  cloud.peer-browse (browsePeer()/loadCatalog()'s direct resources.refresh()
  calls now pass { persist: true } explicitly, matching the pre-existing
  decision already documented at peerBrowseEntry())
- Federation.vue: federation.dwn-status (sync status/counters only)
- MarketplaceAppDetails.vue: app-details:versions (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
  metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
  sibling server.* resources)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:49:16 -04:00
archipelagoandClaude Opus 5 5bfe608893 fix(02-review): purge legacy resource snapshots on schema change (CR-01 follow-up)
CR-01 fixed web5.lnd-info/web5.networking-profits to persist:false, stopping
FUTURE writes to sessionStorage, but a tab already open before the update
ships reloads in-place onto the new bundle and keeps whatever the OLD
bundle already wrote under the old decision — indefinitely, since nothing
but clearAll() (logout) ever purges a resource: snapshot. Long-lived tabs
(installed PWA, kiosk display) are normal here, so this left updating users
exposed to exactly the T-02-01 exposure CR-01 was meant to close.

- Add a schema-version marker (resource:__schema) checked once at store
  setup: absent or stale marker purges every resource:-prefixed
  sessionStorage key, then writes the current version. One-time per tab
  session (a matching marker no-ops), not per navigation/reload, so this
  doesn't defeat the instant-paint-from-snapshot benefit the cache exists
  for. CURRENT_SCHEMA_VERSION must be bumped whenever a key's persist
  decision changes, documented inline as the contract for future changes.
- Extract clearAll()'s purge loop into purgeAllSnapshots(), reused by both
  clearAll() (logout, T-02-02) and the new migration, so there's one place
  that enumerates/removes resource: keys.
- Close the residual refresh()/useCachedResource() default: opts.persist
  ?? true was the exact footgun that caused CR-01 (a call site silently
  opting into persistence by omission). persist is now a required
  parameter on refresh() and useCachedResource()'s options, matching the
  entry()/optimistic() hardening WR-04 already applied.
- Tests: legacy snapshot (no/stale marker) is purged on init; a snapshot
  under the current marker survives a later init (proves one-time, not
  every-boot); persist:false never writes a snapshot; marker is written
  after purge; purge is strictly bounded to the resource: prefix (seeded
  non-resource: sessionStorage keys and a localStorage auth flag survive
  byte-for-byte); migration cannot race an in-flight fetch (runs
  synchronously at store setup, before entries/inflight can hold anything).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:48:36 -04:00
archipelagoandClaude Fable 5 3e3159fa95 test(02-09): pin Server/Web5 KeepAlive round-trip survival via instance uid
Demo images / Build & push demo images (push) Successful in 3m41s
Task 2 (no-op branch, per plan): Task 1's evidence positively proved
Server.vue and Web5.vue's instances already survive tab round-trips —
the "remounts" reading was a probe artifact (02-FINDINGS.md), not a real
defect. No change to DashboardRouterView.vue, dashboardViewWrappers.ts,
keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring.

Lands 4 regression tests in keepAliveLifecycle.test.ts using Vue's own
component-instance identity (vm.$.uid) instead of a CSS selector, so the
pin can't inherit the same generic-.view-container ambiguity Task 1 found:
round-trip identity for Server (Test 1) and Web5 + a second tab (Test 2),
include-list correctness (Test 3), and the LRU cap staying intact (Test 4).
All four pass immediately against the unmodified code — that pass is
itself the pin, per the plan's explicitly anticipated no-change path.

Full suite green (95 files / 778 tests), type-check clean, build succeeds.
keepAliveTabs.test.ts confirmed byte-for-byte unmodified and still green.
No deploy: nothing in neode-ui/src changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 05:50:55 -04:00
archipelagoandClaude b5506025dd fix(02-review): IN-02 document wrapperFor's currently-unreachable full-bleed branch
isFullBleedPath(path) only returns true for /dashboard/chat and
/dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so this branch of
wrapperFor()'s key derivation is currently unreachable. Add a comment
(per the review's own "non-blocking; a comment is sufficient" fix) so a
future reader doesn't mistake the defensive branch for dead code to delete.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 7e4e739e8d fix(02-review): WR-05 forward abort signal through vpnStatus()/dnsStatus()
server.network-summary's fetcher batches four RPCs but only two forwarded
the AbortSignal useCachedResource provides for abort-on-unmount;
rpcClient.vpnStatus()/dnsStatus() had no signal parameter at all, so
aborter.abort() couldn't cancel them, partially defeating the documented
abort-on-unmount contract for this resource.

Add an optional signal parameter to both convenience methods (mirroring
the pattern used throughout rpc-client.ts) and forward it from Server.vue.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 5f7cd4c8bc fix(02-review): WR-04 require explicit persist on resources.ts entry()/optimistic()
entry(key, persist = true) and optimistic(key, update) silently defaulted
to persist:true after the first call for a key, and optimistic() didn't
accept a persist argument at all. Every current call site happened to be
safe, but the invariant was unenforced: a future caller invoking
store.optimistic() before any useCachedResource({persist:false}) has run
for that key in the same tick would silently start writing to
sessionStorage with no indication anything is wrong (T-02-01).

persist is now a required argument on both functions (no default), and the
per-key decision is recorded and asserted (dev-only warning) against any
later call that disagrees. useCachedResource's optimistic() wrapper now
threads its own already-resolved persist value through automatically, so
no existing composable caller changes behavior. The two call sites that
use the resources store directly (Cloud.vue/PeerFiles.vue's per-peer
browse cache) now pass persist:true explicitly, matching their existing
behavior exactly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:39 -04:00
archipelagoandClaude 69358bf6c7 fix(02-review): WR-03 never drop OpenWrtGateway Connect form params under concurrent load
load(params) routed every call -- including the Connect form's own
credentials -- through routerResource.refresh(), which resources.ts dedupes
per key. A second load({host, ssh_user, ssh_password}) call arriving while
an unrelated refresh was already in flight (e.g. useCachedResource's own
TTL-gated auto-revalidation) would just await that already-in-flight
promise; the caller's own params were silently never sent, with no error
surfaced.

load(params) now bypasses routerResource.refresh() entirely when explicit
params are supplied, calling rpcClient directly and writing the resolved
result into routerResource.entry so cache/TTL/status-panel rendering stays
consistent with a normal refresh() success. The plain reconnect path
(no params) is unchanged. The now-redundant pendingParams indirection is
removed since the fetcher only ever needs `{}` params going forward.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 0486045d29 fix(02-review): WR-06 skip redundant fallback init timer on later MeshMap reactivations
armMapVisibility() unconditionally scheduled a setTimeout(initMap, 300) on
every reactivation, not just the first mount. initMap()'s own guard made
this harmless (a no-op once the map exists), but it scheduled a throwaway
timer on every tab-switch back into Mesh. Guard the scheduling itself so
intent ("fallback init for the very first mount") matches behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 751b05f271 fix(02-review): WR-02 stop MeshMap geolocation watch on deactivate, resume on activate
onDeactivated only tore down the resize listener/ResizeObserver, not the
navigator.geolocation.watchPosition watch started by "Share Location" —
leaving GPS polling running in the background (battery drain, active
location indicator) for as long as the KeepAlive'd component survives,
instead of only while the Mesh tab is visible like every other resource
this phase added only-while-visible handling for in this file.

onDeactivated now stops an active watch (tracked via a flag rather than
losing the user's toggle state), and onActivated transparently resumes it
on return to the tab.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 6105770420 fix(02-review): WR-01 decouple Discover's featured-banner data from app-catalog dedup race
Marketplace.vue and Discover.vue both register a useCachedResource against
the shared 'app-catalog' key with different fetchers; resources.ts's
in-flight dedup means whichever view's fetcher wins a given race governs
the shared entry, silently dropping Discover's catalogFeatured side effect
when Marketplace's simpler fetcher wins.

Give the featured-banner payload its own cache key ('app-catalog:featured')
subscribed only by Discover.vue, so it always gets its own data regardless
of which view's fetcher wins the shared 'app-catalog' race. fetchAppCatalog()
already memoizes internally (1h TTL + localStorage fallback), so this is
normally a cache hit rather than an extra network request. The shared
'app-catalog' key and its dedup behavior are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude 57989dfc58 fix(02-review): CR-01 web5.lnd-info/profits resources must not persist to sessionStorage
Web5.vue's lndInfoRes and profitsRes defaulted to persist:true (via
useCachedResource's default), writing live LND wallet balances and
channel balances to sessionStorage in plaintext -- a T-02-01 violation.
Add explicit persist:false to both, matching the "never defaulted"
rule this phase established everywhere else. Also updates Home.vue's
comment, which previously documented this as a known unfixed gap.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 04:17:38 -04:00
archipelagoandClaude Fable 5 8fe6217b5a fix(02-08): cap content.browse-peer fan-out — root cause of Cloud first-visit hang
Demo images / Build & push demo images (push) Successful in 3m29s
Instrumented the exact trigger the fresh-mount guard (previous commit)
didn't fully eliminate: on a fresh session, loadPeerFiles() fired one
content.browse-peer RPC per connected peer with zero concurrency cap
and a 30s per-call timeout. Confirmed on archi-dev-box: 13 of 14
concurrent browse-peer calls never settled at all (dead/unreachable
peers with no server-side timeout on that path) — that many
simultaneously open, indefinitely-pending same-origin requests starved
Chromium's connection pool, silently breaking every other same-origin
fetch for the rest of the session, including the lazy route chunk any
later folder/tab navigation needs. This — not the router or the click
handler — was the actual cause of "no folders open on click" after a
first Cloud visit.

Fix, mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern for
the identical class of problem (content.preview-peer fan-out):
- Cap the browse-peer fan-out at 3 concurrent requests
  (BROWSE_PEER_CONCURRENCY, a queue+worker pool in loadPeerFiles()).
- Shorten each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS) —
  bounds how long any one dead peer can hold a connection.
- Wire an AbortController (aborted onUnmounted) through rpc-client's
  signal option for clean teardown.
- A timed-out/failed peer already resolved silently through
  resources.ts's own error-state path (no throw, no toast) — confirmed
  unchanged; the muted "N peers unreachable" line is the only surface.

No visual/behavioral change to the working case (D-01 rule) — peers
that answer still render exactly as before, just no longer share the
page with a dozen never-ending requests.

Full suite green (95 files / 774 tests), type-check and build clean,
keepAliveTabs.test.ts structural DOM assertions untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:33:32 -04:00
archipelagoandClaude Fable 5 e1a3f31a0a fix(02-08): Cloud.vue fresh-mount guard — first-visit connection-pool stall
Demo images / Build & push demo images (push) Successful in 3m32s
User-reported checkpoint regression: on a genuinely first visit to Cloud
this session, no folder opened on click (subsequent visits fine).
Root-caused on archi-dev-box, not guessed:

- The click DID register (confirmed via a direct DOM listener) and DID
  call router.push (confirmed by patching the live router instance) —
  but the push's promise never settled, because Vue Router awaits the
  target route's async component, and that dynamic import() itself
  never resolved.
- Confirmed via a manual import() from the page console: importing ANY
  lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views)
  hangs identically after visiting Cloud once, but works instantly
  before ever visiting Cloud. Not chunk-specific, not router-specific.
- Traced to exactly one permanently-pending network request: a File
  Browser `GET /app/filebrowser/api/resources/Photos` call from Cloud's
  own onMounted burst, confirmed hung via request-lifecycle tracking
  (never finishes or fails, still pending after 10s). The identical
  request, issued manually with a fresh token outside of Cloud.vue,
  returns in 29ms — ruling out the backend/File Browser itself.
- Mechanism: Cloud.vue's syncOnEntry() (loadCounts/loadPeers/
  loadPeerFiles) fires from BOTH onMounted and onActivated with no
  fresh-mount guard (02-04 exempted it, reasoning each resource is
  individually staleness/inflight-deduped — true per-resource, but the
  two back-to-back passes still double the concurrent request volume
  at the single riskiest instant in a session: first KeepAlive
  activation, stacked on whatever other cached view's own onMounted
  burst is firing at the same moment). On real hardware that volume
  was enough to leave one File Browser request stuck, which then
  starves Chromium's per-origin connection pool — breaking every
  subsequent same-origin fetch, including the lazy chunk any later
  navigation needs. Not a router or click-handler bug; the click and
  push both worked correctly the whole time.

Fix: give Cloud.vue the same fresh-mount guard already used in
Home.vue/Web5.vue/Mesh.vue/Server.vue (skip onActivated's redundant
first-activation re-fire since onMounted just ran it). Removes the
duplicate-burst mechanism without changing steady-state reactivation —
onActivated still re-syncs normally on every later KeepAlive round-trip.
No visual/behavioral change (D-01 rule).

Full test suite green (95 files / 774 tests), type-check and build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:44:35 -04:00