Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit adcd0f7ce8
1826 changed files with 403825 additions and 0 deletions
@@ -0,0 +1,151 @@
---
phase: quick-260729-fw7
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/src/views/mesh/HopVizModal.vue
- neode-ui/src/views/Mesh.vue
- neode-ui/src/views/mesh/mesh-styles.css
autonomous: true
requirements: [QUICK-FW7]
must_haves:
truths:
- "Clicking a mesh message's transport pill (or the ⋯ button) opens a hop-route modal that is visually balanced on desktop — the hop chain fills the modal width, endpoint nodes are prominent, and the modal is no longer a cramped 420px card with tiny emoji + dot row"
- "On narrow viewports (phone width) the hop chain renders VERTICALLY — sender at top, relays stacked, recipient at bottom — with the packet animation traveling top-to-bottom, nothing overflowing or wrapping awkwardly"
- "The visualization uses Archipelago's design language: glass panel, dark-only palette, per-transport accent colors (meshcore orange #fb923c, meshtastic mint #3eb489, reticulum blue #60a5fa, lora amber #f59e0b, fips violet #a78bfa, tor indigo #818cf8), EQ-segment-style bars echoing ScreensaverRing, node glow, staggered reveal, animated packet traveling the path"
- "prefers-reduced-motion disables all looping animations (existing behavior preserved)"
- "Tor / FIPS / unknown-transport cases still render their distinct shapes (3 anonymous relays / direct P2P / not recorded), and SNR/RSSI + E2E/delivery metadata still display"
artifacts:
- "neode-ui/src/views/mesh/HopVizModal.vue — new self-contained modal component (Teleport to body) with scoped styles"
- "neode-ui/src/views/Mesh.vue — inline hop-viz modal markup replaced by <HopVizModal>"
- "web/dist/neode-ui/ — rebuilt bundle containing the new component's class strings"
key_links:
- "Mesh.vue transport pill / ⋯ button click → hopVizMsg → <HopVizModal :msg :peer @close> renders"
- "HopVizModal derives accent color from msg.transport, matching the pill colors in mesh-styles.css lines 168-173"
---
<objective>
Redesign the mesh-message hop visualization modal in neode-ui: properly sized and balanced on desktop, vertical stacked layout on mobile, and fully on-brand (glass, per-transport accents, EQ-segment motif from ScreensaverRing, animated packet travel, node glow, staggered reveal).
User feedback (verbatim): "please make the hop graphic and animation on mesh messages much better balanced, the desktop one is very small and it doesn't work on mobile where it should be vertical, and make it much more archipelago style and branded, make it beautiful."
Purpose: The current hop viz (inline in Mesh.vue lines ~2627-2678, styles in mesh-styles.css lines ~597-637) is a cramped horizontal flex row inside a 420px modal — tiny emoji endpoints, a dashed border with blinking `•` dots, no mobile handling. It reads as an afterthought, not an Archipelago feature.
Output: New `HopVizModal.vue` component wired into Mesh.vue, old inline markup and `.mesh-hopviz-*` CSS removed, rebuilt bundle in web/dist/neode-ui/ verified to contain the new strings.
</objective>
<context>
**Current implementation (read all of these first):**
- `neode-ui/src/views/Mesh.vue` lines ~1291-1310 (hopVizMsg / hopVizPeer / hopVizHops state), lines ~2340-2350 (transport pill + ⋯ button that set `hopVizMsg`), lines ~2627-2678 (the inline Teleported modal to replace). Also `transportLabel()` and `signalQualityLabel()` helpers used by the modal — grep for them in Mesh.vue.
- `neode-ui/src/views/mesh/mesh-styles.css` lines ~160-175 (transport pill accent colors + `.mesh-chat-e2e`), ~572-584 (`.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, title/sub/cancel — SHARED with the send-transport and image-quality modals, do not break them), ~597-637 (`.mesh-hopviz-*` rules to delete/migrate).
- `neode-ui/src/components/ScreensaverRing.vue` — the brand EQ-segment motif: thin 4px rounded bars, white gradient fill, staggered `scaleY` pulse keyframes. Echo this visual language for relay-node markers.
- `neode-ui/tailwind.config.js` — glass tokens (glass-dark/glass-border/shadow-glass), fonts: `Montserrat` = `font-archipelago` header font; app is dark-only.
- Global focus glow + accent used app-wide: orange `rgba(251,146,60,…)` (#fb923c).
**Transport accent map (must match pill colors):** meshtastic `#3eb489`, meshcore `#fb923c`, reticulum `#60a5fa`, lora `#f59e0b`, fips `#a78bfa`, tor `#818cf8`.
**Data realities:** For LoRa transports only a hop COUNT is known (`peer.hops`, 0 or 0xff/null = direct), not per-relay identities — render count relays as anonymous branded markers. Tor = fixed "3 anonymous relays" shape. FIPS = direct P2P. `null` transport = not recorded. SNR/RSSI are current link readings (keep the existing disclaimer note).
**Project rules that apply:**
- Modals MUST `<Teleport to="body">` with full-screen backdrop (already true — preserve it).
- Frontend build can silently no-op: after `npm run build`, grep the built bundle in `web/dist/neode-ui/` for new strings before claiming done.
- Commit the code changes when they work; push via `git push gitea-ai main`. Stage explicitly by path (`git add <paths>`), never `git add -A` — other agents may share the tree.
- Do not deploy to any node; this rides the normal dev-pair → OTA pipeline later.
</context>
<tasks>
<task type="auto">
<name>Task 1: Build HopVizModal.vue — branded, balanced desktop hop visualization</name>
<files>neode-ui/src/views/mesh/HopVizModal.vue, neode-ui/src/views/Mesh.vue, neode-ui/src/views/mesh/mesh-styles.css</files>
<action>
Create `neode-ui/src/views/mesh/HopVizModal.vue` (script setup, TypeScript) and move the hop-viz modal out of Mesh.vue into it.
Component contract:
- Props: `msg: MeshMessage` (import type from `../../types/api` or wherever Mesh.vue imports it), `peer: MeshPeer | null`, plus the two label strings Mesh.vue already computes (`transportLabel` result and `signalQualityLabel` result) OR import/reuse those helpers if they are importable; if they are local functions in Mesh.vue, pass computed strings as props — do NOT duplicate logic.
- Emits: `close`.
- Template: `<Teleport to="body">` wrapping a full-screen backdrop (reuse `.mesh-transport-modal-backdrop` class so backdrop behavior stays consistent) with `@click.self="emit('close')"`, containing a `glass-card` panel.
Visual redesign (all styles SCOPED in the component; delete the old `.mesh-hopviz-*` rules from mesh-styles.css lines ~597-637 — but leave `.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, `.mesh-transport-title/sub/cancel` untouched since the send-transport and image-quality modals still use them; the new panel should use its own width class, wider than 420px — target `min(560px, 94vw)` on desktop):
1. **Header:** transport-colored title using the Montserrat brand font (`font-family: 'Montserrat', sans-serif` or the `font-archipelago` utility), e.g. "MeshCore route", with the existing You → peer subtitle. Derive an `--hop-accent` CSS custom property on the panel root from `msg.transport` using the exact pill color map (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback rgba(251,146,60) orange). All accents below use `var(--hop-accent)`.
2. **Endpoint nodes (You / peer):** substantial circular medallions (~64-72px) instead of bare emoji — island glyph 🏝️ centered inside a ring of 12-16 EQ-style segments (thin rounded bars radiating like a compact ScreensaverRing — reuse its technique: absolutely-positioned bars, `transform: rotate(deg) translateY(-radius)`, staggered `scaleY` pulse animation, white-to-transparent gradient tinted with the accent). Soft accent glow behind each medallion (`box-shadow: 0 0 24px color-mix(...)` or an rgba shadow). Node name below in white 600-weight, ellipsized.
3. **Path between endpoints:** replace the dashed-border + blinking `•` row with a proper track: a horizontal line/gradient in the accent color connecting the medallions, with:
- Relay markers for each hop (LoRa transports: `Math.min(hops, 6)` markers; Tor: exactly 3 with a 🧅/anonymous treatment; FIPS: no relays, a single direct link) rendered as small EQ-segment clusters or glowing accent dots (~10-14px) sitting ON the track, each with its own subtle pulse, staggered.
- An animated packet: a small bright dot/comet (accent color, blurred glow trail) traveling from sender to recipient along the track on an infinite ~2s loop. Use a CSS keyframe translating along the track container (like the existing `mesh-hopviz-travel` sweep but as a discrete glowing packet, not a background sheen).
- Label under/over the track: "direct radio link" / "N hops" / "3 anonymous relays" / "direct peer-to-peer" / "transport wasn't recorded" — preserve the existing per-transport template branches and copy.
- Staggered entrance: sender medallion, then track+relays, then recipient fade/slide in (keep the existing appear pattern, ~0.05/0.35/0.65s delays).
4. **Metadata footer:** keep the SNR/RSSI signal row (LoRa only, with the existing "current link readings" disclaimer note) and the E2E / delivered ✓✓ / timestamp row, restyled as small glass chips consistent with `.mesh-transport-meta` sizing. Keep the Close button (`.mesh-transport-cancel` class is fine).
5. **Reduced motion:** wrap ALL looping animations (packet, segment pulses, relay pulses) and entrance animations in the component's own `@media (prefers-reduced-motion: reduce)` block that disables them — the old CSS did this; the new component must too.
Wire-up in Mesh.vue: import HopVizModal, replace the inline `<Teleport>` block at lines ~2627-2678 with `<HopVizModal v-if="hopVizMsg" :msg="hopVizMsg" :peer="hopVizPeer" ... @close="hopVizMsg = null" />`. Keep `hopVizMsg`/`hopVizPeer`/`hopVizHops` state in Mesh.vue (or move `hopVizHops` into the component — it only needs `peer.hops`; prefer moving it in to shrink Mesh.vue). Do not touch the transport pill / ⋯ button triggers.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vue-tsc --noEmit 2>/dev/null || npm run build</automated>
</verify>
<done>HopVizModal.vue exists with the medallion + track + packet design, Mesh.vue renders it in place of the inline modal, old `.mesh-hopviz-*` rules removed from mesh-styles.css, other transport modals' shared classes untouched, type-check/build passes.</done>
</task>
<task type="auto">
<name>Task 2: Mobile vertical layout</name>
<files>neode-ui/src/views/mesh/HopVizModal.vue</files>
<action>
Add responsive behavior inside HopVizModal.vue's scoped styles. At narrow widths (`@media (max-width: 560px)` — pick the breakpoint so a typical phone portrait always gets it):
- The chain flips to a COLUMN: sender medallion at top, vertical track with relay markers stacked below it, recipient medallion at bottom. Implement so the same DOM works in both orientations (flex-direction column + a track that switches from horizontal line to vertical line), rather than duplicating markup.
- The packet animation travels TOP-TO-BOTTOM along the vertical track (a second keyframe or a transform-based animation that follows the flex axis).
- Medallions may shrink slightly (~56px) but stay prominent; names and hop label must not truncate mid-word or overflow the panel; panel uses near-full width (`width: 94vw`) with comfortable vertical padding, and the whole modal scrolls (`max-height: 90vh; overflow-y: auto`) if metadata pushes it tall.
- Entrance stagger and reduced-motion handling apply identically in vertical mode.
Sanity-check both orientations in the browser via the dev preview (`npm run dev`, viewport toggling in devtools) if a display is available; otherwise rely on the CSS being purely breakpoint-driven and symmetric.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy/neode-ui && grep -q "max-width: 560px" src/views/mesh/HopVizModal.vue && grep -qi "column" src/views/mesh/HopVizModal.vue</automated>
</verify>
<done>Below the breakpoint the hop chain renders vertically (sender top → recipient bottom) with the packet traveling downward; no overflow; desktop layout unchanged above the breakpoint.</done>
</task>
<task type="auto">
<name>Task 3: Build, verify bundle, commit</name>
<files>web/dist/neode-ui/ (build output), neode-ui/src/views/mesh/HopVizModal.vue</files>
<action>
1. `cd neode-ui && npm run build` (outputs to `web/dist/neode-ui/`).
2. Per CLAUDE.md, prove the build actually picked up the change: grep the built JS/CSS bundle for a new unique string from the component (e.g. a distinctive class name like `hopviz-medallion` or `hopviz-packet` — whatever class names Task 1 used; pick one that did not exist before): `grep -rl "hopviz-packet" web/dist/neode-ui/assets/` (adjust the token to the actual class name). It MUST match; if it doesn't, the build silently no-opped — investigate before proceeding.
3. Also confirm the OLD inline markup is gone from the bundle source of truth: `grep -c "mesh-hopviz-chain" neode-ui/src/views/Mesh.vue` returns 0.
4. Commit the code changes only (docs/planning files are committed by the orchestrator): `git add neode-ui/src/views/mesh/HopVizModal.vue neode-ui/src/views/Mesh.vue neode-ui/src/views/mesh/mesh-styles.css web/dist/neode-ui` — stage exactly these paths, never `git add -A`. Check `git status` first for other agents' unrelated changes and leave them alone. Commit message: `feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile` ending with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer. Push: `git push gitea-ai main`.
</action>
<verify>
<automated>grep -rl "hopviz" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/ | head -1 && cd /home/archipelago/Projects/archy && git log --oneline -1 | grep -qi "hop"</automated>
</verify>
<done>Fresh build in web/dist/neode-ui/ contains the new component's class strings, Mesh.vue no longer contains the old inline hopviz markup, and the change is committed and pushed via gitea-ai.</done>
</task>
</tasks>
<verification>
- `npm run build` succeeds in neode-ui/; bundle in web/dist/neode-ui/ contains a new hopviz class string (silent-no-op guard).
- Old `.mesh-hopviz-*` rules removed; `.mesh-transport-modal-backdrop` / `.mesh-transport-option` / image-quality modal styles untouched (grep mesh-styles.css and open the send-transport modal path in code to confirm shared classes intact).
- Modal still Teleports to body with full-screen backdrop.
- All five transport branches render: meshcore/meshtastic/reticulum (hop count), tor (3 relays), fips (direct), null (not recorded).
- prefers-reduced-motion block present in the new component.
<human-check>On the dev preview (:8100 or `npm run dev`), open Mesh chat, click a message's transport pill: desktop shows the wide balanced medallion+packet layout; shrinking the window below the breakpoint flips it vertical. Confirm it "feels Archipelago" — glass, accent glow, EQ-segment motif.</human-check>
</verification>
<success_criteria>
- Desktop hop modal is visually balanced: ~560px panel, prominent glowing endpoint medallions with EQ-segment rings, accent-colored track with animated traveling packet and staggered relay markers.
- Mobile (< 560px) renders the chain vertically top-to-bottom with the packet traveling downward; nothing overflows.
- Per-transport accent colors match the existing transport pill colors exactly.
- Reduced-motion users get a static layout.
- Built bundle verified to contain the new strings; code committed and pushed via gitea-ai.
</success_criteria>
<output>
Executor commits code only. On completion, note results for the orchestrator; no SUMMARY.md required for quick mode unless the orchestrator asks.
</output>
@@ -0,0 +1,85 @@
---
phase: quick-260729-fw7
plan: 01
subsystem: neode-ui/mesh
tags: [frontend, mesh, hop-viz, branding, animation, responsive]
requires: []
provides:
- HopVizModal.vue branded hop-route visualization component
affects:
- neode-ui mesh chat (transport pill / ⋯ route modal)
tech-stack:
added: []
patterns:
- Self-contained Teleport-to-body modal component with scoped styles
- EQ-segment ring motif (ScreensaverRing technique) reused for endpoint medallions
- CSS custom property accent theming (--hop-accent) derived per transport in JS
key-files:
created:
- neode-ui/src/views/mesh/HopVizModal.vue
modified:
- neode-ui/src/views/Mesh.vue
- neode-ui/src/views/mesh/mesh-styles.css
decisions:
- "Label strings (transportLabel/signalQualityLabel/timeAgo) passed as props from Mesh.vue — no logic duplication"
- "hopVizHops moved into the component (derived from peer.hops); hopVizMsg/hopVizPeer state stays in Mesh.vue"
- "web/dist/neode-ui NOT committed — web/ is gitignored (.gitignore:74); build output is intentionally untracked in this repo"
metrics:
duration: ~15m
completed: 2026-07-29
status: complete
---
# Quick Task 260729-fw7: Mesh Hop-Route Visualization Redesign Summary
**One-liner:** Replaced the cramped 420px inline hop-viz modal with a self-contained branded HopVizModal.vue — 560px balanced desktop layout with EQ-segment-ringed glowing medallions, per-transport accent track with animated traveling packet and staggered relay markers, flipping to a vertical sender-top→recipient-bottom chain below 560px.
## What Was Built
- **`neode-ui/src/views/mesh/HopVizModal.vue`** (new, ~430 lines): Teleport-to-body modal, `min(560px, 94vw)` glass panel, `max-height: 90vh` scrollable.
- Montserrat transport-colored title; `--hop-accent` / `--hop-accent-soft` / `--hop-accent-faint` CSS vars derived from `msg.transport` matching the chat pill colors exactly (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback orange).
- Endpoint medallions: 72px (56px mobile), island glyph on an accent-tinted disc with glow, ringed by 14 EQ segments using the ScreensaverRing rotate+translateY+scaleY-pulse technique.
- Track: accent gradient line, relay markers positioned fractionally along it — mini 3-bar EQ clusters for radio hops (`min(hops, 6)`), 🧅 ×3 for Tor, none for FIPS/unknown (unknown dims the line). Animated white/accent glowing packet travels sender→recipient on a 2.2s loop.
- Per-transport labels preserved verbatim: "direct radio link" / "N hops" / "3 anonymous relays" / "FIPS overlay · direct peer-to-peer" / "transport wasn't recorded".
- Staggered entrance (0.05/0.35/0.65s), metadata as glass chips (signal + SNR/RSSI + disclaimer note; E2E badge + delivered ✓✓ + time).
- `@media (max-width: 560px)`: chain flips to column, track becomes a vertical line, packet animates top→bottom (`hopviz-packet-y`), same DOM.
- `@media (prefers-reduced-motion: reduce)`: all loops and entrance animations disabled, packet hidden.
- **`Mesh.vue`**: inline 50-line Teleport block replaced by `<HopVizModal>`; label helpers passed as computed props; `hopVizHops()` removed (moved into component).
- **`mesh-styles.css`**: all `.mesh-hopviz-*` rules and their keyframes/reduced-motion block deleted; `.mesh-chat-transport-clickable`, `.mesh-transport-modal-*` (shared with send-transport + image-quality modals), and `.mesh-chat-more-btn` untouched (12 shared-class occurrences verified intact).
## Commits
| Task | Commit | Description |
| ---- | ------ | ----------- |
| 13 | `ac09fc5d` | feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile |
## Verification
- `npx vue-tsc --noEmit` → exit 0.
- `npm run build` → success; bundle guard (CLAUDE.md silent-no-op rule): `hopviz-packet` and `hopviz-medallion` found in `web/dist/neode-ui/assets/Mesh-D2ImmPoh.js` + `Mesh-Dy7zKwro.css`; old `mesh-hopviz-chain` string absent from both source and bundle.
- Task 2 grep checks: `max-width: 560px` + `column` present in HopVizModal.vue.
- Submodule guard run before commit (no indeedhub paths staged); no file deletions in the commit.
## Deviations from Plan
**1. [Rule 3 - Blocking] `web/dist/neode-ui` not staged/committed**
- **Found during:** Task 3
- **Issue:** Plan said to stage `web/dist/neode-ui`, but `web/` is gitignored (`.gitignore:74`) and untracked — build output is intentionally excluded from the repo (release tarballs are built from it at ship time).
- **Fix:** Committed the three source files only; fresh build exists on disk in `web/dist/neode-ui/` and was grep-verified.
**2. Push deferred to orchestrator** — plan Task 3 said `git push gitea-ai main`, but the executor constraints state the orchestrator handles pushing; not pushed here.
## Known Stubs
None — no placeholder text, hardcoded-empty data paths, or unwired components introduced.
## Human Verification Pending
On the dev preview (`npm run dev` or :8100), open a mesh chat and click a message's transport pill: desktop shows the wide medallion+packet layout; shrinking below 560px flips it vertical. Confirm brand feel (glass, accent glow, EQ motif).
## Self-Check: PASSED
- FOUND: neode-ui/src/views/mesh/HopVizModal.vue
- FOUND: commit ac09fc5d on main
- FOUND: hopviz strings in web/dist/neode-ui/assets/ (fresh build)
- CONFIRMED: 0 occurrences of `mesh-hopviz` in Mesh.vue and mesh-styles.css
@@ -0,0 +1,348 @@
---
phase: quick-260729-gjd
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/docker/nginx-demo.conf
- neode-ui/docker/indee-demo-signin.js
- neode-ui/Dockerfile.web
- docker-compose.demo.yml
- demo-deploy/docker-compose.yml
- neode-ui/src/composables/useDemoIntro.ts
- neode-ui/src/views/appSession/useAppIdentity.ts
- neode-ui/mock-backend.js
autonomous: true
requirements: [QUICK-260729-GJD]
must_haves:
truths:
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
artifacts:
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
key_links:
- "demoAppUrl('indeedhub') → http://<demo-host>:2101/ → nginx :2101 server block → https://indee.tx1138.com upstream"
- "nginx sub_filter → /__demo/indee-demo-signin.js → seeds indeedhub-accounts + indeedhub-active-account localStorage keys → IndeeHub boot-restore logs the visitor in"
- "staticDevApps['indeedhub'] → structuredClone into per-session package-data → My Apps grid on fresh session"
---
<objective>
Make IndeeHub a first-class app in the PUBLIC DEMO only: (1) the real site
https://indee.tx1138.com/ renders inside the in-app iframe session (today it is
frame-busted by `X-Frame-Options: SAMEORIGIN` and opens externally), (2) a demo
visitor sees IndeeHub already signed in with a throwaway demo Nostr identity
(no login wall, no identity-picker modal), and (3) IndeeHub appears as an
already-installed, running app on a completely fresh demo session.
Purpose: the demo currently punts IndeeHub to a new tab with a login wall —
the flagship media app looks broken/hostile to demo visitors.
Output: demo-scoped changes across nginx-demo.conf, a new sign-in seed script,
the two demo compose files, useDemoIntro.ts, useAppIdentity.ts, mock-backend.js.
All behavior changes are gated behind IS_DEMO / demo-image build paths. The
real-node build must be completely unaffected.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@neode-ui/src/composables/useDemoIntro.ts
@neode-ui/docker/nginx-demo.conf
@neode-ui/Dockerfile.web
@neode-ui/Dockerfile.backend
@docker-compose.demo.yml
@demo-deploy/docker-compose.yml
@neode-ui/src/views/appSession/appSessionConfig.ts
@neode-ui/src/views/appSession/useAppIdentity.ts
@neode-ui/src/views/appSession/useNostrBridge.ts
@neode-ui/src/stores/appLauncher.ts
@neode-ui/mock-backend.js
</context>
<verified_findings>
Facts confirmed by inspection on 2026-07-29 (do not re-derive, but re-verify
the live-bundle details marked "verify at exec time"):
- `curl -sI https://indee.tx1138.com/``X-Frame-Options: SAMEORIGIN`, no
Content-Security-Policy header today (strip both defensively).
- The live index.html loads a hashed module bundle from absolute-root paths
(`/assets/index-*.js`, `/icons/...`, `/manifest.json`). This is why the old
`/app/indeedhub/` path-prefix + sub_filter proxy broke (asset/router paths
escape the prefix). A WHOLE-ORIGIN proxy on a dedicated port has no such
problem — the SPA sees itself at `/` and every relative call just works.
- The live bundle (assets/index-BMWtjRCn.js) uses applesauce-accounts:
- localStorage key `"indeedhub-accounts"` = JSON array of serialized
accounts (`Fe.toJSON`/`Fe.fromJSON`), restored on boot before UI renders.
- localStorage key `"indeedhub-active-account"` = active account id.
- A private-key account class exists whose `fromJSON` does
`const t=Vn(e.signer.key); new mr(e.pubkey, new ai(t))` — i.e. shape is
`{ id, type: "<verify at exec time>", pubkey, signer: { key: "<hex sk>" } }`
plus common fields from `loadCommonFields` (verify exact `type` string and
common fields by grepping the live bundle for `static type` / the class's
`toJSON`). An `"extension"` account type also exists (fallback path).
- The bundle expects NIP-07 as a real `window.nostr` object ("Signer
extension missing" guard) — it does NOT contain the archipelago
`nostr-request` postMessage client.
- Parent-side NIP-07 plumbing already exists: `AppSession.vue` line ~423
routes `nostr-request` messages to `useNostrBridge`, which calls
`node.nostr-pubkey` (mocked in mock-backend.js) and `node.nostr-sign`
(NOT implemented in mock-backend.js). Only needed for the fallback approach.
- `useAppIdentity.ts`: `isIdentityAwareApp('indeedhub')` is true → on iframe
load with no stored identity it opens the identity-picker modal. In the demo
this is a blocking modal the visitor shouldn't see.
- `appLauncher.ts openSession`: `IS_DEMO && isDemoExternal(appId)` is the only
thing forcing indeedhub external; `NEW_TAB_APP_IDS` is already bypassed when
`IS_DEMO && isDemoApp(appId)`. `AppSession.vue mustOpenNewTab` has the same
two-clause shape. Removing indeedhub from `DEMO_EXTERNAL_URLS` while keeping
`isDemoApp('indeedhub')` true flips it to the iframe path everywhere.
- `mock-backend.js`: per-visitor session state is initialized via
`md['package-data'] = structuredClone(staticDevApps)` (~line 5493), so
adding an entry to `staticDevApps` (~line 828) makes it installed on every
fresh session. `APP_PORTS`-style map at ~line 323 already has
`'indeedhub': 8190`; an icon exists at `/assets/img/app-icons/indeedhub.png`.
- `Dockerfile.web` copies `nginx-demo.conf` to `/etc/nginx/nginx.conf.template`
and runs `docker-entrypoint-custom.sh` (env substitution) — read the
entrypoint before editing so the new server block's nginx `$vars` survive
templating the same way the existing blocks' do.
- Both Docker builds already scrub + fail on any occurrence of the private
release-server IP; nothing in this change may hardcode host IPs — build the
iframe URL from `window.location.hostname`.
- `indeedhub/` at repo root is a git submodule (not checked out) — NEVER stage
any path under it. `indeedhub-demo/` is a prior standalone-build attempt
(clones the GitHub fork, builds with VITE env); this plan supersedes it by
proxying the LIVE site instead — leave that directory untouched.
</verified_findings>
<tasks>
<task type="auto">
<name>Task 1: nginx whole-origin proxy on :2101 + sign-in seed script + compose ports</name>
<files>neode-ui/docker/nginx-demo.conf, neode-ui/docker/indee-demo-signin.js, neode-ui/Dockerfile.web, docker-compose.demo.yml, demo-deploy/docker-compose.yml, neode-ui/docker/docker-entrypoint.sh</files>
<action>
Add a second `server` block to nginx-demo.conf: `listen 2101;` that is a
pure whole-origin reverse proxy of `https://indee.tx1138.com` — no path
prefix, no URL rewriting (this is the fix for the documented sub_filter
breakage: the SPA keeps its own absolute-root paths). In that block:
`location / { proxy_pass https://indee.tx1138.com; }` with
`proxy_ssl_server_name on;`, `proxy_ssl_name indee.tx1138.com;`,
`proxy_set_header Host indee.tx1138.com;`,
`proxy_http_version 1.1;` + WebSocket upgrade headers (reuse the existing
`$connection_upgrade` map), `proxy_hide_header X-Frame-Options;` and
`proxy_hide_header Content-Security-Policy;`. For HTML injection:
`proxy_set_header Accept-Encoding "";` (upstream must not gzip or
sub_filter no-ops), `sub_filter_types text/html;`, `sub_filter_once on;`,
`sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';`
— a classic (non-module) script injected at end of head still executes
BEFORE the SPA's deferred module bundle, which is what the seeding needs.
Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }`
(or alias) inside the 2101 server so the seed script is served same-origin
to the iframe. Update the comment block that currently explains why
IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design.
Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic
script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and
that the embedded key is a freshly generated THROWAWAY demo identity, not
a real secret. Generate ONE fresh secp256k1 keypair at implementation time
(e.g. `node -e` with a tiny script using any available schnorr/secp lib, or
a one-off `npx` of nostr-tools in the scratchpad — the generator itself is
not committed) and embed hex sk + hex pk as constants. The script: if
`localStorage.getItem('indeedhub-accounts')` is empty/absent, write the
two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array
with ONE serialized private-key account: verify the exact `type` string
and common-field shape against the live bundle per verified_findings, shape
`{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields`
round-trips, give it a friendly name/metadata like "Archy Demo" if the
shape supports it) and `indeedhub-active-account` (that account's id).
Because the script runs on the :2101 origin inside the iframe, this
touches only the proxied app's isolated storage. IndeeHub then restores
the account on boot and self-signs with its own bundled signer — no
window.nostr and no parent bridge required. Do NOT define a partial
`window.nostr` in this approach (a pubkey-only shim with a broken
signEvent causes worse failures than no shim).
FALLBACK (only if live testing in Task-3 verification shows the seeded
account shape is not accepted): seed an `"extension"`-type account
instead, define a `window.nostr` postMessage client in this same script
(request/response protocol matching useNostrBridge: post
`{type:'nostr-request', id, method, params}` to `window.parent`, resolve on
`{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` /
`identity.nostr-sign` in mock-backend.js with real schnorr signatures over
the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is
pure JS and Dockerfile.backend runs `npm install` over package.json).
Prefer the primary approach; only fall back with evidence.
Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is
already inside `neode-ui/` so the existing `COPY neode-ui/ ./` +
dist copy do NOT ship it — add an explicit
`COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js`
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
never in real-node artifacts). Publish the port in docker-compose.demo.yml
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
file's header comment). Read docker-entrypoint.sh first and make sure the
new server block survives its template substitution exactly like the
existing blocks (same escaping convention for nginx `$` variables); touch
the entrypoint only if its substitution list needs it.
Do not put any host IP in any of these files; upstream hostname
indee.tx1138.com is fine.
</action>
<verify>
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
</verify>
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
</task>
<task type="auto">
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
<files>neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts</files>
<action>
In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete
the map entirely if it becomes empty, simplifying `isDemoExternal` to
return false — keep the exported function so call sites in appLauncher.ts
and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')`
return the proxied origin built at runtime:
`${window.location.protocol}//${window.location.hostname}:2101/`
(hostname, never a hardcoded host/IP — works on any deploy host). Keep
`isDemoApp('indeedhub')` true (it must stay in the demoable set so the
NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab
keeps routing it into the in-app iframe session, and so the install
button stays enabled). Update the file-header comment block that
currently documents the external-tab workaround to describe the :2101
whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA)
but guard `typeof window !== 'undefined'` if other tests import the module
in node context — check the existing unit tests under
src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions
about indeedhub being demo-external and update them to the new behavior.
In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from
useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`,
when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor
must not be interrupted by an identity modal (the embedded IndeeHub is
already signed in via the seeded account from Task 1, and `sendIdentity`'s
`identity.sign` RPC is not what logs it in). Real-node behavior
(picker on first launch) is untouched because IS_DEMO is compile-time
false there.
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK</automated>
</verify>
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
</task>
<task type="auto">
<name>Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions</name>
<files>neode-ui/mock-backend.js</files>
<action>
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
(match the existing title map at ~line 537 and APP_TITLES), a short/long
description consistent with the marketplace copy ("Bitcoin documentary
streaming platform" per the existing entry), `state: 'running'`,
`lanPort: 8190` (matches the existing port map), icon
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
`structuredClone(staticDevApps)`, this alone makes it installed+running on
every fresh session. Then reconcile the rest of the mock so nothing
contradicts installed status: check the marketplace/available-apps mock
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
4900+) for `indeedhub` entries that would render it as not-installed or
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
mock as-is unless Task 1's fallback path was taken (in which case align
its pubkey with the throwaway demo key and add the sign handlers described
there).
</action>
<verify>
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
</verify>
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
| public visitors → demo host | anyone can drive the proxy |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
</threat_model>
<verification>
Local (executor, before commit):
1. nginx config parses (Task 1 verify).
2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build
unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of
the plain build — that is EXPECTED; the bundle-grep for demo strings must be
done on the VITE_DEMO=1 build, which is exactly what the demo Docker image
builds (Dockerfile.web defaults ARG VITE_DEMO=1).
3. Fresh-session mock package-data includes indeedhub (Task 3 verify).
4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up
--build` locally, browse http://localhost:2100 in a private window →
login `entertoexit` → IndeeHub installed → launch → iframe renders the
proxied site from http://localhost:2101 with a signed-in account.
5. `git status` — confirm nothing under indeedhub/ is staged, ever.
Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100):
1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options
header and the injected `indee-demo-signin.js` tag in the HTML body
(`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the
port is unreachable, the vps2 firewall needs 2101 opened — flag to
orchestrator.
2. Fresh private browser window → :2100 → login → IndeeHub shows installed/
running on the dashboard/My Apps without any install action.
3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a
new tab; content browsable; no identity-picker modal.
4. Signed-in check: IndeeHub header shows an active account (avatar/profile
instead of a sign-in button). If the seeded account shape was rejected
(login wall still visible), execute the documented fallback (extension
account + window.nostr shim + mock signer) and redeploy.
5. View-source/network spot-check: no occurrence of the private
release-server IP in any served response.
6. Repeat-visit check: reload the iframe once — a service worker registered by
IndeeHub may serve cached HTML without the injected tag on later loads;
that is acceptable because localStorage is already seeded on first load,
but confirm sign-in persists.
</verification>
<success_criteria>
- Demo visitor on a fresh browser sees IndeeHub installed, launches it into
the in-app iframe, and browses indee.tx1138.com content signed in — zero
clicks spent on install/login/identity modals.
- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated).
- No secrets committed beyond the labelled throwaway demo key; nothing staged
under indeedhub/; demo serves no private release-server IP.
- Work committed in focused commits (infra / frontend / mock) with the
Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to
the orchestrator.
</success_criteria>
<output>
Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done.
</output>
@@ -0,0 +1,107 @@
---
phase: quick-260729-gjd
plan: 01
subsystem: public-demo
tags: [demo, indeedhub, nginx, reverse-proxy, nostr, mock-backend]
requires: []
provides:
- "IndeeHub whole-origin demo proxy on :2101 (framing headers stripped, sign-in seeded)"
- "Demo iframe launch of indeedhub via demoAppUrl → <host>:2101/"
- "IndeeHub pre-installed/running on every fresh demo session"
affects: [demo-deploy, neode-ui demo image]
tech-stack:
added: []
patterns:
- "Whole-origin per-port reverse proxy for frame-busting external SPAs (vs broken path-prefix sub_filter)"
- "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)"
key-files:
created:
- neode-ui/docker/indee-demo-signin.js
modified:
- neode-ui/docker/nginx-demo.conf
- neode-ui/Dockerfile.web
- docker-compose.demo.yml
- demo-deploy/docker-compose.yml
- neode-ui/src/composables/useDemoIntro.ts
- neode-ui/src/views/appSession/useAppIdentity.ts
- neode-ui/mock-backend.js
decisions:
- "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle"
- "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)"
metrics:
duration: "~50 min"
completed: 2026-07-29
status: complete
---
# Quick Task 260729-gjd: IndeeHub in the Demo Summary
**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session.
## Commits
| Task | Commit | Scope |
|------|--------|-------|
| 1 | 69bc3d3f | nginx :2101 whole-origin proxy + indee-demo-signin.js seeder + Dockerfile.web COPY/EXPOSE + both compose files publish 2101 |
| 2 | 66d540f8 | useDemoIntro: DEMO_EXTERNAL_URLS → DEMO_PROXY_PORTS, demoAppUrl builds `<protocol>//<hostname>:2101/`; useAppIdentity: picker suppressed under IS_DEMO |
| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session |
## What was verified at exec time (live-bundle facts)
- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`.
- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON``{ signer: { key: <hex sk> }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode.
- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing.
## Throwaway demo identity
Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed):
- pk `7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31`
- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key.
## Local verification results
1. **nginx parse:** `nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference).
2. **Live proxy smoke (podman, config + seeder mounted):** `curl` through :2101 → 200, **no X-Frame-Options / CSP**, injected `<script src="/__demo/indee-demo-signin.js">` present in HTML, seed script served same-origin, hashed asset `/assets/index-BMWtjRCn.js` proxied 200.
3. **Unit tests:** 195/195 green (`src/views/appSession` + `src/stores`), running with IS_DEMO=false — non-demo path exercised.
4. **Demo build:** `VITE_DEMO=1 npm run build` succeeded; bundle (`web/dist/neode-ui/assets/index-ChDwfLt5.js`) contains the 2101 launch logic. This is exactly what Dockerfile.web builds (ARG VITE_DEMO=1 default).
5. **Mock backend:** boots with DEMO=1; `/ws/db` initial dump of a fresh session contains `indeedhub` with `state=running`, ui=true, lan `http://localhost:8190`.
6. **No IP leaks:** none of the changed files contain the private release-server IP; pre-existing occurrences in dist (catalog.json/marketplace data) are scrubbed+gated by the existing Dockerfile.web guard.
7. **Submodule guard:** ran before all three commits; nothing under `indeedhub/` ever staged. No file deletions in any commit.
## Deviations from Plan
**1. [Minor] Dropped `sub_filter_types text/html` from the :2101 block**
- **Found during:** Task 1 nginx parse check
- **Issue:** nginx warns `duplicate MIME type "text/html"` — text/html is sub_filter's built-in default type
- **Fix:** removed the redundant directive (identical behavior), noted in a comment
- **Commit:** 69bc3d3f
**2. [Environment] Plain (non-demo) build + vue-tsc typecheck could not be run — permission denied**
- Three attempts (`npm run build`, `vite build --outDir <scratch>`, `vue-tsc --noEmit`) were denied by the permission system mid-execution.
- **Proxy coverage:** 195 unit tests ran and passed under IS_DEMO=false (compiles + non-demo runtime behavior), and the demo build compiled the same source. All changed TS is IS_DEMO-gated with types unchanged at call sites.
- **Residual risk:** low; a plain `npm run build` before the next real-node frontend ship will confirm (it runs vue-tsc).
**3. [Flag] `web/dist/neode-ui/` currently holds a VITE_DEMO=1 bundle**
- The demo verification build overwrote the (gitignored) `web/dist/neode-ui` output. **Rebuild with a plain `npm run build` before any real-node frontend deploy/ISO/OTA that rsyncs `web/dist`** — do not ship the demo bundle to real nodes.
## Fallback status
The plan's fallback (extension-type account + window.nostr postMessage shim + mock signer) was **not needed** — the primary seeded-nsec path matches the live bundle's restore contract exactly. If post-deploy testing shows a login wall anyway, the fallback is fully documented in the PLAN (Task 1 action block).
## Post-deploy checklist for orchestrator (vps2, after demo image rebuild + redeploy)
1. `curl -sI http://146.59.87.168:2101/` → 200, NO `X-Frame-Options`; `curl -s http://146.59.87.168:2101/ | grep indee-demo-signin` → hit. **If unreachable: open port 2101 in the vps2 firewall** (new requirement of this change).
2. Fresh private window → `http://146.59.87.168:2100` → login `entertoexit` → IndeeHub shows installed/running in My Apps with no install step.
3. Launch IndeeHub → renders inside the in-app iframe session (not a new tab), content browsable, **no identity-picker modal**.
4. Signed-in check: IndeeHub header shows an active account (avatar/profile, not a sign-in button). Note: the throwaway key has no published kind-0 profile, so expect a default avatar/truncated npub rather than a named profile — that still counts as signed in. If a login wall appears, execute the documented fallback and redeploy.
5. Spot-check served responses for the private release-server IP (should be none; build guard enforces).
6. Reload the iframe once — sign-in must persist (localStorage already seeded even if a service worker serves cached HTML without the injected tag).
7. Reminder: the `demo-deploy` thin stack now publishes `${DEMO_INDEE_PORT:-2101}:2101` — the public archy-demo repo copy of that compose file needs syncing when the images ship.
## Self-Check: PASSED
- neode-ui/docker/indee-demo-signin.js — FOUND
- neode-ui/docker/nginx-demo.conf :2101 block — FOUND
- Commits 69bc3d3f, 66d540f8, d00ca624 — FOUND in git log
- No paths under indeedhub/ in any commit — VERIFIED
- SUMMARY frontmatter status: complete — SET
@@ -0,0 +1,376 @@
---
phase: quick-260729-hj1
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/mock-backend.js
- neode-ui/src/views/PeerFiles.vue
- demo/content/music/ (2 new Wavlake mp3s)
- demo/peer-media/ (Wavlake artwork + replaced photo-*.jpg files)
autonomous: true
requirements: [A1, A2, A3, A4, A5, A6, A7, A8]
user_setup: []
must_haves:
truths:
- "Searching the Cloud files search for the Wavlake track titles returns them as peer-file results from at least one demo peer (A1, A2)"
- "The first Wavlake track is PAID; buying it via the demo ecash flow immediately autoplays it in the bottom GlobalAudioPlayer bar (A3)"
- "Cloud -> Paid Files tab shows pre-seeded purchase-history entries (sats paid + date); clicking an audio purchase plays it in the bottom bar (A4)"
- "The aggregated Peer Files tab shows each library item on ~1 peer, with only 2-3 items deliberately duplicated across peers (A5)"
- "Photo peer files show real photographs as card previews and in the full-screen viewer, not picsum placeholders (A6)"
- "In the PeerFiles gallery, clicking a FREE image opens the full-screen image viewer (A8); free audio -> bottom player; free video -> video modal; paid unowned items stay gated behind the pay modal (A7)"
- "Real-node build unaffected: all existing unit tests stay green, npm run build succeeds, and the frontend changes degrade gracefully when the real backend omits demo-only fields"
artifacts:
- "demo/content/music/: two downloaded Wavlake mp3s (real bytes, committed)"
- "demo/peer-media/: Wavlake artwork jpg(s) + photo-*.jpg replaced with real photographs"
- "neode-ui/mock-backend.js: Wavlake PEER_LIBRARY entries, deduped peerCatalogFor, seeded+persistent owned-content state, real-bytes paid/owned downloads, /api/peer-content streaming route"
- "neode-ui/src/views/PeerFiles.vue: click-to-open viewer routing incl. free-image lightbox fix, unified post-payment in-app open/autoplay"
key_links:
- "content.download-peer-paid must return real bytes + correct mime_type -> confirmEcashPay's audio branch -> audioPlayer.play (this is the A3 autoplay chain; today the mock returns text/plain which breaks it)"
- "new /api/peer-content/:onion/:id mock route -> free-audio streaming AND the free-image viewer src (frontend already builds this URL at PeerFiles.vue:1011/1470; it 404s in the demo today)"
- "seeded content.owned-list entries must reference onions of the SAME session's demoFederationNodes() output and items actually present in that peer's peerCatalogFor slice, or Owned badges will not line up"
- "mock content.owned-list must include session purchases, otherwise loadOwned() (called after every purchase at PeerFiles.vue:1314) wipes the just-bought Owned state"
---
<objective>
Peer-files media batch for the public demo plus two shared-behavior fixes.
Demo/mock only (A1-A6): add two real Wavlake tracks (metadata + bytes fetched at
execution time), make the first one PAID with a working pay -> autoplay-in-bottom-bar
flow, seed visible purchase history, dedupe the peer catalog to ~2-3 intentional
duplicates, and replace picsum placeholder photos with real photographs.
Both builds (A7, A8): clicking a peer file opens the appropriate viewer (photos ->
full-screen image viewer, audio -> bottom music player, video -> video modal), and
fix the confirmed bug that FREE images are a click no-op in the PeerFiles gallery
(PeerFiles.vue line 91 ternary falls through to `undefined` for non-playable free items).
Purpose: the peer-files demo is a flagship "buy content over the mesh" showcase; today
paid purchases unlock a text placeholder, free images don't open, free streaming 404s,
and the catalog is visibly duplicate-heavy.
Output: updated mock-backend.js + committed demo media assets + PeerFiles.vue behavior
fixes, tests green, built bundle verified.
</objective>
<context>
## Verified recon (do not re-derive; spot-check only)
**Wavlake API (probed 2026-07-29, works from this network):**
- Track 1 (user link `wavlake.com/track/3504d80b-...`):
`https://catalog.wavlake.com/v1/tracks/3504d80b-b4bf-4196-923b-7ed8b60caec9`
returns `data.title = "WEBFIVEFOURTHREETWOONE"`, `data.artist = "Zazawowow"`,
`data.albumTitle = "WEBFIVE"`, `data.duration = 249`,
`data.artworkUrl = https://d12wklypp119aj.cloudfront.net/image/ed6c75e5-e469-4f52-b073-a18b237dadae.jpg`,
and a stream URL whose direct CDN form is
`https://d12wklypp119aj.cloudfront.net/track/3504d80b-b4bf-4196-923b-7ed8b60caec9.mp3`
(HTTP 200, `content-type: audio/mpeg`, `content-length: 6041903`, accept-ranges,
NO signature/expiry params — stable).
- Album (user link `wavlake.com/album/9be3cdce-...`):
`https://catalog.wavlake.com/v1/albums/9be3cdce-015e-4e7e-8ffe-c017945465c4`
returns title "Michael Michael Saylor" (single) by Zazawowow with one track id
`ba80e385-62a5-4309-ac1e-bb0493b8539f`. Fetch that id from the tracks endpoint for
its metadata; its mp3 follows the same CDN pattern
(`https://d12wklypp119aj.cloudfront.net/track/ba80e385-62a5-4309-ac1e-bb0493b8539f.mp3`).
- Hotlink-vs-download criterion (decided): DOWNLOAD the mp3s + artwork into the repo.
The CDN URLs are stable, but the mock serves content as base64 bytes / disk files
(`content.download-peer`, `content.preview-peer` read `entry.disk` with
`fsSync.readFileSync`), so committed local files are the architecturally consistent
choice and remove any CORS/expiry/offline risk. This matches the existing pattern —
`demo/content/music/*.mp3` and `demo/peer-media/*.jpg` are already committed real files.
External wavlake/CDN URLs would be allowed under the demo IP-leak rule (only
146.59.87.168 must never appear), but are simply not needed.
**Mock backend (`neode-ui/mock-backend.js`, port 5959, RPC at `/rpc/v1`):**
- `PEER_LIBRARY` at line ~1256: films/series/books paid (no disk bytes), music/photos/
docs free with `disk:` pointing at committed files under `demo/content/` and
`demo/peer-media/` (helpers `PEER_MEDIA`/`PEER_CONTENT` at lines 1254-1255).
- `peerCatalogFor(onion)` line ~1321: hash-based slice; the
`((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` clause puts each item on ~25% of the
12 peers (`demoFederationNodes()` line ~1339, onions are RANDOM per session) —
this is the heavy-duplication source (A5).
- `content.preview-peer` (line ~2358): serves `entry.preview` or the image's `disk`.
- `content.download-peer` (line ~2372): serves `entry.disk` bytes, else text placeholder.
- `content.owned-list` (line ~2402): returns `{ items: [] }` — no purchase history, and
it CLOBBERS just-bought state (see key_links).
- `content.owned-get` / `content.download-peer-paid` / `-invoice` / `-onchain`
(lines ~2405-2425): all return a TEXT PLACEHOLDER (`mime_type: text/plain`) — this is
why paid autoplay (A3) cannot work today. Ecash rail deduction logic here must be kept.
- There is NO `/api/peer-content/:onion/:id` route — the frontend's free-item stream
URL (PeerFiles.vue lines 1011 and 1470) 404s in the demo.
**Frontend (`neode-ui/src/views/PeerFiles.vue`, 1616 lines):**
- Card click (line 91): `isOwned ? viewOwned : (isPlayable ? playMedia : undefined)`
free images fall to `undefined` = the A8 bug. `isPlayable` (line 963) is video/audio only.
- `viewOwned` (line 702): audio -> `audioPlayer.play` (bottom bar), image/video -> the
Teleport-to-body "Owned-content viewer" modal (template line 310, footer hardcodes
"Owned · unlocked" at line 354).
- `confirmEcashPay` (line 1277): on success marks owned, audio -> `audioPlayer.play`
(A3 autoplay ALREADY implemented here — only the mock's text/plain response breaks it),
then `void loadOwned()` (line 1314).
- `payWithLightning` (line 1364), `pollInvoice` (line 1417), `pollOnchain` (line 1197):
on success these call `triggerDownload` (browser download) instead of the in-app
open/autoplay path — inconsistent with confirmEcashPay.
- `playMedia` (line 1460): free audio -> `audioPlayer.play(streamUrl)`, free video ->
video modal via the same `/api/peer-content/` stream URL; paid uses
`content.preview-peer` bytes ("10% preview"). NOTE: for a PAID AUDIO item the
pre-purchase Preview button plays whatever `content.preview-peer` returns — so for the
paid Wavlake track the mock MUST return audio bytes there, not artwork.
- Preview thumbnails (watcher line 872): fetched ONLY for image/video mimes; audio cards
show a waveform icon. The real backend's `content.preview-peer`
(core/archipelago/src/api/rpc/content.rs:1113) proxies the seller's
`/content/{id}/preview` — for audio that is audio bytes, so do NOT extend the
thumbnail watcher to audio (it would fetch audio blobs as "thumbnails" on real nodes).
- `Cloud.vue`: Paid Files tab (line ~155) lists `content.owned-list` items
(`PaidItem { onion, content_id, filename, mime_type, size_bytes, paid_sats,
purchased_at }`) and `viewPaidItem` (line ~470) plays audio in the bottom bar — this
is the A4 purchase-history surface, already built; it only needs seeded data.
Cloud search (`runSearch` line ~880) filters the aggregated `peerFiles` by filename —
Wavlake items are searchable as soon as they appear in any peer's catalog slice.
- `GlobalAudioPlayer` is mounted in `App.vue` (line 48); `useAudioPlayer` is a global
singleton — nothing to change there.
- Tests: `src/views/__tests__/PeerFilesRefresh.test.ts` mounts PeerFiles.vue — keep green.
**Project rules that bind this work:**
- Modals/lightboxes must Teleport to body with full-screen backdrop (the existing
owned-content viewer already complies — reuse it).
- Do not add new UI entry points (no new tabs/cards/nav); only change behavior of
existing elements.
- NEVER stage/commit anything under `indeedhub/` (git submodule). Stage explicitly by
path (`git add <paths>`), never `git add -A`.
- Never expose 146.59.87.168 in demo-served content.
- Commit each task when it works, message trailer `Co-Authored-By: Claude ...`.
Executor commits code only; docs/summary are committed by the orchestrator.
</context>
<tasks>
<task type="auto">
<name>Task 1: Demo dataset — Wavlake tracks, real photos, dedupe, purchases, real paid bytes, streaming route</name>
<files>neode-ui/mock-backend.js, demo/content/music/ (2 new mp3s), demo/peer-media/ (artwork + replaced photo-*.jpg)</files>
<action>
All changes in this task are demo/mock-only (mock-backend.js is not part of the
real-node build).
1. Fetch Wavlake assets at execution time (A1, A2). Use curl against the verified
endpoints in context: resolve track 3504d80b-b4bf-4196-923b-7ed8b60caec9 and the
album 9be3cdce-015e-4e7e-8ffe-c017945465c4's single track
ba80e385-62a5-4309-ac1e-bb0493b8539f via catalog.wavlake.com/v1/tracks/{id}.
Parse real title/artist/albumTitle/duration/artworkUrl from the JSON (jq or node -e)
— do NOT hardcode metadata from this plan except the ids; the API is the source of
truth. Download each track's CDN mp3 into demo/content/music/ (filename derived from
real title, e.g. "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3") and each artworkUrl jpg
into demo/peer-media/. Sanity-check downloads with file(1): mp3s must be MPEG audio
(~6MB expected for track 1), artwork must be JPEG. Abort and report if the API shape
changed.
2. Add both tracks to PEER_LIBRARY (A1-A3). mime_type audio/mpeg, real size_bytes
(actual file size), disk: PEER_CONTENT('music', <filename>), description carrying
real artist/album/duration metadata (this makes artist searchable too). First track
(WEBFIVEFOURTHREETWOONE): access paid with a price the demo ecash balance easily
covers (e.g. 21 sats). Second track: access 'free'. For the PAID track, give it a
preview behavior consistent with playMedia: content.preview-peer for audio entries
must return AUDIO bytes (read entry.disk; serving the full mp3 is acceptable for the
demo, or the first ~10% byte-slice to match the "10% preview" badge — mp3 frames
tolerate truncation), NOT the artwork jpg — see the recon note on playMedia's paid
path. Keep image entries' preview behavior unchanged.
3. Dedupe peerCatalogFor (A5). Replace the probabilistic
`((seed ^ (i * 2654435761)) >>> 0) % 12 < 3` inclusion with a deterministic
assignment: each PEER_LIBRARY item lives on exactly one peer (e.g. index-based
slot), plus a small explicit POPULAR list of 2-3 item ids that additionally appear
on 1-2 more peers (a little duplication is realistic). Keep the function signature
and per-session determinism (same onion -> same slice within a session). Ensure both
Wavlake tracks land on at least one trusted peer's slice so Cloud search finds them.
4. Real photos (A6). Replace all ten demo/peer-media/photo-*.jpg picsum placeholders
with real photographs downloaded at execution time from a stable free-license source
(Wikimedia Commons Special:FilePath URLs with a width parameter, e.g. ?width=1200,
are reliable and hotlink-free once committed). Pick images that match each entry's
existing description (aurora over fjord, mountain lake, neon city rain, desert
dunes, forest mist, ocean cliff, northern road, autumn valley, harbor dawn, alpine
ridge) or update the descriptions (including the "sourced via picsum.photos"
credit text and the stale PEER_LIBRARY header comment) to match reality. Keep the
photo-*.jpg filenames so PEER_MEDIA references don't change; update each entry's
size_bytes to the new actual file size. Verify each file with file(1) is a real
JPEG of reasonable resolution (>= ~1000px wide).
5. Owned/purchase state (A3, A4). Introduce a mock owned-content store (e.g.
mockState.ownedContent array of { onion, content_id, filename, mime_type,
size_bytes, paid_sats, purchased_at }). Lazily seed it on first access with 2-4
plausible past purchases dated days-to-weeks ago, referencing onions from the
CURRENT session's demoFederationNodes() output and content ids actually present in
that peer's peerCatalogFor slice (onions are random per session — compute, don't
hardcode). At least one seeded purchase must be an audio item backed by real disk
bytes so the Paid Files tab click plays music. content.owned-list returns this
store (all fields of Cloud.vue's PaidItem interface). Every successful purchase
path (content.download-peer-paid, and the -invoice/-onchain and onchain/invoice
status flows' download calls) appends an entry with the real price and
purchased_at=now, so the Owned badge survives the post-purchase loadOwned()
refresh and purchases show up in the Paid Files tab.
6. Real bytes for paid/owned downloads (A3). Rework the shared
content.owned-get / content.download-peer-paid / -invoice / -onchain case: look up
the PEER_LIBRARY entry by content_id; when entry.disk exists return the real file
bytes with mime_type entry.mime_type; keep the text placeholder only as fallback
for entries without disk bytes (the fictional 2GB films). Preserve the existing
ecash rail-deduction logic exactly. This makes buying the Wavlake track deliver a
real mp3 with mime_type audio/mpeg, which is what confirmEcashPay's existing audio
branch needs to autoplay in the bottom bar.
7. Streaming route. Add an Express GET route /api/peer-content/:onion/:content_id
to mock-backend.js that resolves the PEER_LIBRARY entry and serves entry.disk via
res.sendFile (Express handles Range/206 automatically — the frontend probes with
Range: bytes=0-0), 404 JSON { error } otherwise. This unbreaks the demo's existing
free-audio/video streaming and is required by Task 2's free-image viewer. Verify
the demo/dev proxy actually forwards /api/* to the mock (check vite.config.ts /
vite.preview.config.mts proxy config and the demo docker nginx config if present);
if /api is not proxied in dev, register the route on whatever path prefix reaches
the mock and keep the frontend URL unchanged (the frontend path is fixed — it must
work on real nodes too, where nginx proxies /api to the daemon).
Commit this task on its own (stage mock-backend.js + the demo/ media files
explicitly by path; git add demo/content/music demo/peer-media is fine, never
git add -A; nothing under indeedhub/).
</action>
<verify>
<automated>cd neode-ui && node --check mock-backend.js && node mock-backend.js & sleep 2; then RPC-probe localhost:5959: (a) content.browse-peer for each federation onion — every PEER_LIBRARY id appears on >=1 peer, total duplicate ids across all peers <= 3, both Wavlake ids present; (b) content.owned-list returns >=2 seeded items with paid_sats and purchased_at; (c) content.download-peer-paid for the paid Wavlake id returns mime_type audio/mpeg with data length > 1MB base64; (d) curl -H "Range: bytes=0-0" /api/peer-content/{onion}/{free-wavlake-id} returns 206; (e) file demo/peer-media/photo-*.jpg all report JPEG; then kill the mock. (If /rpc/v1 requires a session, log in first with the mock demo password password123.)</automated>
</verify>
<done>Both Wavlake tracks in the catalog with real committed bytes + real API metadata; first is paid and delivers real audio/mpeg bytes on purchase; owned-list seeded and purchase-persistent; catalog duplication reduced to <=3 intentional items; photos are real JPEGs; /api/peer-content serves Range requests. Committed.</done>
</task>
<task type="auto">
<name>Task 2: Shared viewer routing — free-image lightbox fix, click-to-open, unified post-payment open</name>
<files>neode-ui/src/views/PeerFiles.vue</files>
<action>
These changes ship in BOTH the real-node build and the demo — no IS_DEMO gating,
and every path must degrade gracefully against the real backend.
1. Fix the A8 bug + A7 click routing at the card click handler (line 91). Replace
the ternary with a single openItem(item) dispatcher: owned -> viewOwned (existing);
paid-unowned playable -> playMedia (existing 10% preview, keep); paid-unowned
NON-playable (images, blurred) -> openPayModal(item) so the click gets the
appropriate "viewer" for locked content while preserving the paid gating (the
full image is never fetched or revealed pre-purchase — only the blurred thumbnail);
FREE image -> open the existing Teleport-to-body owned-content viewer modal
(template line 310) as a lightbox: set viewerItem/viewerMime and point viewerUrl
at the free stream URL /api/peer-content/{onion}/{id} directly (an img src streams
it; no base64 round-trip; works on real nodes via the Range proxy and in the demo
via Task 1's new route). Free audio/video already route through playMedia — keep.
Guard closeViewer's URL.revokeObjectURL so it only revokes blob: URLs.
2. Generalize the viewer footer (line 354): the hardcoded "Owned · unlocked" green
caption must only show for owned items; for free items show a neutral caption
(exact string "Free · shared by peer" — also used as the bundle-grep sentinel), and
make the footer Save button use the free download path for free items (streamDownload)
instead of content.owned-get.
3. Unify post-payment success handling (A3-adjacent, both builds). payWithLightning
(line 1364), pollInvoice (line 1417) and pollOnchain (line 1197) currently
triggerDownload on success; align them with confirmEcashPay (line 1277): mark the
item owned in ownedKeys, refresh loadOwned, and open in-app — audio ->
audioPlayer.play (bottom-bar autoplay), image/video -> the viewer modal — using the
mime_type from the download response with item.mime_type as fallback. Extract the
shared logic (e.g. an openPurchased(item, data, mime) helper) rather than
duplicating it four times. Keep triggerDownload available via the viewer's Save
button. Do not touch the payment/polling logic itself.
4. Do NOT extend the preview-thumbnail watcher (line 872) to audio — on real nodes
content.preview-peer returns audio bytes for audio items (see recon), which must
not be used as an img src. Audio cards keep the waveform icon.
5. Sanity-check the other previews-grid views for the same A8 class of bug:
CloudFolder.vue / Cloud.vue My Files use FileCard @preview -> handlePreview ->
MediaLightbox and should already open free images; verify by reading the handler
chain (no change expected — do not modify them if they work).
6. Keep src/views/__tests__/PeerFilesRefresh.test.ts green; if the click-dispatch
refactor is cheaply testable, extend that test file with a case asserting a free
image click sets viewerUrl (do not build new test infrastructure).
Commit this task separately (stage neode-ui/src paths explicitly).
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/views/__tests__/PeerFilesRefresh.test.ts src/composables/__tests__/useAudioPlayer.test.ts</automated>
</verify>
<done>Clicking a free image in PeerFiles opens the full-screen viewer (Teleport-to-body, backdrop preserved); free audio -> bottom bar; free video -> video modal; paid unowned image click opens the pay modal and never reveals the image; all four payment-success paths open purchased content in-app with audio autoplaying in the bottom bar. Targeted tests green. Committed.</done>
</task>
<task type="auto">
<name>Task 3: Full test suite, production build, bundle verification, demo smoke</name>
<files>(no new files — verification only; fixes belong to the task that broke them)</files>
<action>
1. Run the full unit suite: cd neode-ui && npx vitest run — all tests (currently
195) must pass. Fix any regression in the file that caused it, amending or adding
a fixup commit to the responsible task's change.
2. Production build per CLAUDE.md: cd neode-ui && npm run build (vue-tsc must pass;
output lands in web/dist/neode-ui). Grep the built bundle for the Task 2 sentinel
string to prove the build did not silently no-op:
grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ must match at least
one js file.
3. End-to-end demo smoke against the mock: start node mock-backend.js plus the dev
frontend (or vite preview against the built dist) and exercise via curl/RPC: search
corpus contains the Wavlake titles (browse-peer aggregation), paid purchase of the
Wavlake track deducts ecash and returns audio/mpeg, owned-list grows by the
purchase, /api/peer-content serves the free track with 206. Confirm no occurrence
of 146.59.87.168 in mock-backend.js additions or demo-served data:
grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/ must be empty.
4. Confirm git hygiene: git status shows nothing staged under indeedhub/; all work
is committed across the task commits (code only — the summary doc is the
orchestrator's commit). Leave deploy/push to the orchestrator.
5. Write into the task summary a post-deploy live checklist for
http://146.59.87.168:2100 (orchestrator deploys): search finds the Wavlake tracks;
buy the paid track with ecash -> bottom bar autoplays; Paid Files tab shows seeded
purchases; peer catalog shows <=3 duplicated files; photos are real and open in the
lightbox on click; free song click plays in bottom bar.
</action>
<verify>
<automated>cd neode-ui && npx vitest run && npm run build && grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ | head -1 && ! grep -rn "146.59.87.168" mock-backend.js ../demo/</automated>
</verify>
<done>Full suite green, production build succeeds, bundle grep proves the new UI string shipped, demo smoke passes, no IP leak, clean git state with per-task commits.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| executor -> catalog.wavlake.com / CloudFront / Wikimedia | External bytes fetched at execution time get committed into the repo and served by the demo |
| demo visitor -> mock backend | Untrusted public visitors hit the new /api/peer-content route |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-hj1-01 | Tampering | Downloaded mp3/jpg assets | medium | mitigate | Verify every download with file(1) magic-type + plausible size before committing; fetch only from the verified catalog.wavlake.com/CloudFront/Wikimedia URLs over https |
| T-hj1-02 | Information Disclosure | Demo-served data | high | mitigate | Task 3 gate: grep for 146.59.87.168 across mock-backend.js and demo/ must be empty |
| T-hj1-03 | Tampering (path traversal) | /api/peer-content route | medium | mitigate | Route resolves content_id strictly against PEER_LIBRARY entries (whitelist lookup, never a filesystem path built from request input) |
| T-hj1-04 | Elevation | Paid-content gating in PeerFiles.vue | medium | mitigate | Paid-unowned image click opens the pay modal only; the free-image viewer path is reachable solely when access !== paid; no pre-purchase full-content fetch is added |
</threat_model>
<verification>
- `cd neode-ui && npx vitest run` — full suite green (195 tests baseline).
- `cd neode-ui && npm run build` — vue-tsc + vite build succeed; bundle grep for "Free · shared by peer" hits.
- Mock RPC smoke: Wavlake items searchable via browse-peer aggregation; paid purchase returns audio/mpeg real bytes and appends to owned-list; duplicate ids across all 12 peers <= 3; /api/peer-content answers 206 to a Range probe; owned-list seeded with dated purchases.
- `git log --oneline` shows one focused commit per task; nothing staged under indeedhub/.
</verification>
<success_criteria>
- A1/A2: both Wavlake tracks (real API metadata, real committed bytes) searchable in the peer-files search.
- A3: first Wavlake track is paid; demo ecash purchase autoplays it in the bottom GlobalAudioPlayer bar.
- A4: Cloud -> Paid Files shows seeded purchase history; audio purchases play on click; new purchases persist in the list.
- A5: at most 2-3 deliberately duplicated files across the aggregated peer catalog.
- A6: all photo peer files are real photographs matching their descriptions.
- A7/A8 (both builds): free image click opens the full-screen viewer, free audio -> bottom player, free video -> video modal, paid gating preserved; all payment paths open purchased media in-app.
- Non-demo build safe: tests green, build clean, frontend degrades gracefully without demo-only mock data.
</success_criteria>
<output>
On completion the executor reports per-task commit hashes and the post-deploy live
verification checklist for http://146.59.87.168:2100 (deployment is the orchestrator's
job; docs/summary committed by the orchestrator).
</output>
@@ -0,0 +1,89 @@
---
phase: quick-260729-hj1
plan: 01
subsystem: demo / peer-files
status: complete
requirements: [A1, A2, A3, A4, A5, A6, A7, A8]
key-files:
created:
- "demo/content/music/Zazawowow - WEBFIVEFOURTHREETWOONE.mp3 (6,041,903 B, MPEG audio)"
- "demo/content/music/Zazawowow - Michael Michael Saylor.mp3 (4,517,590 B, MPEG audio)"
- demo/peer-media/artwork-webfive.jpg (1400x1400 JPEG)
- demo/peer-media/artwork-michael-saylor.jpg (1400x1400 JPEG)
modified:
- neode-ui/mock-backend.js
- neode-ui/vite.config.ts
- neode-ui/src/views/PeerFiles.vue
- neode-ui/src/views/__tests__/PeerFilesRefresh.test.ts
- demo/peer-media/photo-*.jpg (all 10 replaced with real Wikimedia Commons photos)
commits:
- "14d1a453 feat(demo): Wavlake tracks, real photos, deduped peer catalog, working paid flow"
- "f52c5407 fix(peer-files): free-image lightbox, click-to-open routing, in-app open after every payment rail"
metrics:
duration: 64m
tasks: 3
completed: 2026-07-29
---
# Quick Task 260729-hj1: Peer-Files Media Batch (Wavlake, Paid Track, Real Photos) Summary
Two real Wavlake tracks (paid buy->autoplay flow now delivers real mp3 bytes), seeded purchase history, deduped peer catalog, ten real Commons photographs, and a shared-frontend fix so every peer file click opens the right viewer (free images finally open in the lightbox).
## What Was Done
### Task 1 — Demo dataset (commit 14d1a453, demo/mock only)
- **Wavlake tracks (A1-A3):** metadata fetched live from `catalog.wavlake.com/v1/tracks/{id}` (title/artist/album/duration confirmed against plan recon), mp3 bytes + 1400x1400 artwork downloaded from the CloudFront CDN and committed. `song-webfive` ("WEBFIVEFOURTHREETWOONE" by Zazawowow, 21 sats, PAID) and `song-michael-saylor` (free). Both verified as real MPEG audio with `file(1)`.
- **Real paid bytes (A3):** the shared `content.owned-get` / `download-peer-paid` / `-invoice` / `-onchain` handler now returns real disk bytes with the correct `mime_type` when the entry has them (text placeholder only for the fictional no-disk films/books). Ecash rail-deduction logic preserved verbatim.
- **Purchase history (A4):** per-session `sessionOwnedContent()` store lazily seeds 3 dated purchases (song-builders 100 sats — audio with real bytes; film-the-signal 2100; book-cypherpunk-essays 210), computed against the session's own federation onions/catalog slices. Every purchase path appends, so Owned badges survive the post-purchase `loadOwned()` refresh. `song-builders` was converted from free to paid (100 sats) so the seeded history contains a playable paid audio item without pre-owning the showcase Wavlake track.
- **Dedupe (A5):** `peerCatalogFor` is now a deterministic one-peer-per-item assignment (index % 12 against the session's node order) plus a 3-item POPULAR list (`song-webfive`, `film-block-height`, `photo-aurora-fjord`) that appears on exactly one extra peer each. Verified: 29/29 ids present, exactly 3 duplicated ids, both Wavlake tracks on trusted peers.
- **Session-stable onions:** `demoFederationNodes()` was regenerated (fresh random onions) on every RPC call; it is now memoised per session (`sessionFederationNodes()`) so catalogs, owned records and the Federation view agree.
- **Real photos (A6):** all ten `photo-*.jpg` picsum placeholders replaced with real Wikimedia Commons photographs (aurora over Lofoten fjord, Lago di Limides/Dolomites, Dotonbori Osaka neon, Erg Chebbi dunes, Black Forest mist, Cliffs of Moher, Iceland winter road, Stowe VT autumn, St Ives harbour, Grindelwald ridge hiker). Each is >=1920px wide, visually inspected, license-credited in its description; `size_bytes` updated to real sizes; filenames kept so `PEER_MEDIA` refs are unchanged.
- **Streaming route:** new `GET /api/peer-content/:onion/:content_id` serving `entry.disk` via `res.sendFile` (Range/206 works — probed). Whitelist lookup only (T-hj1-03), paid entries return 403 so full paid bytes are unreachable without purchase. Demo nginx already proxies `/api/` -> :5959; added the missing `/api` proxy to the vite dev config so dev works too.
- **Paid audio preview:** `content.preview-peer` serves a ~10% leading slice of the real mp3 for audio entries (the Preview button plays it), images unchanged.
### Task 2 — Shared viewer routing (commit f52c5407, both builds)
- **A8 fix + A7 routing:** card click goes through `openItem()`: owned -> cached viewer/bottom bar; paid playable -> 10% preview; paid non-playable -> pay modal (image never fetched pre-purchase, T-hj1-04); free image -> existing Teleport-to-body viewer as a lightbox with `viewerUrl` pointed at the stream URL (no base64 round-trip); free audio/video -> `playMedia` (bottom bar / video modal).
- **Viewer footer:** "Owned · unlocked" (green) only for owned items; "Free · shared by peer" (neutral) otherwise — also the bundle-grep sentinel. Save button streams free files (`streamDownload`) instead of calling `content.owned-get`.
- **Unified post-payment:** `payWithLightning`, `pollInvoice` and `pollOnchain` now share `openPurchased()` with the ecash flow — mark owned, refresh owned list, audio autoplays in the bottom bar, image/video open in the viewer. `triggerDownload` remains for the explicit Save path.
- **Blob-URL hygiene:** `releaseViewerUrl()` only revokes `blob:` URLs (free items use plain URLs).
- Preview-thumbnail watcher deliberately NOT extended to audio (real nodes return audio bytes there). Cloud.vue/CloudFolder.vue verified to already open free images via MediaLightbox — untouched.
- Regression test added: free image click opens the lightbox.
### Task 3 — Verification
- Full unit suite: **697/697 passed** (baseline in plan said 195; suite has since grown — all green).
- `npm run build` (vue-tsc + vite): success; `grep -rl "Free · shared by peer" web/dist/neode-ui/assets/` hits `PeerFiles-DsotBwvS.js` (build did not no-op).
- Mock RPC smoke (20 checks, all pass): browse-peer aggregation contains both Wavlake titles; paid purchase deducts 21 sats ecash and returns `audio/mpeg` >1MB; purchase appended to owned-list; `/api/peer-content` answers 206 to a Range probe, 403 for paid items, 404 for unknown/traversal ids; paid audio preview is audio bytes; image previews still jpeg.
- `grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/` — empty (T-hj1-02).
- Git: two focused code commits, nothing staged under `indeedhub/`, only other agents' pre-existing untracked files remain.
## Deviations from Plan
**1. [Rule 3 - Blocking] Session-memoised federation nodes.** The plan assumed onions were "random per session"; they were actually random per RPC call, which would have made seeded owned-content onions never match what the frontend sees. Added `sessionFederationNodes()` memoisation (per-visitor via the existing session store). Commit 14d1a453.
**2. [Rule 2 - Missing critical] `/api` dev proxy in vite.config.ts.** The demo nginx proxies `/api/` to the mock, but the vite dev server did not — the new route (and the existing `/api/blob`, `/api/app-catalog`) would 404 on :8100 dev. Added a `/api` proxy entry (dev-server-only config; real nodes use nginx). Commit 14d1a453.
**3. [Minor scope choice] `song-builders` converted free -> paid (100 sats).** The plan required a seeded audio purchase backed by real disk bytes, but the only paid audio item is the showcase Wavlake track, which must NOT be pre-owned (it would kill the A3 buy demo). Making one existing song paid provides a legitimately purchasable audio item for the seeded history and an Owned-badge example in the gallery.
## Post-Deploy Live Checklist — http://146.59.87.168:2100 (orchestrator deploys)
1. **Search (A1/A2):** Cloud -> search "Zazawowow" (or "WEBFIVE" / "Michael") — both tracks appear as peer-file results.
2. **Paid buy -> autoplay (A3):** open the peer holding "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3" (21 sats), Buy -> ecash -> Pay: the bottom GlobalAudioPlayer bar appears and the track audibly plays; card flips to green "Owned".
3. **Preview before buying (A3):** the paid track's Preview button plays ~25s of real audio, not silence/artwork.
4. **Paid Files tab (A4):** Cloud -> Paid Files shows 3+ seeded purchases with sats + dates; clicking "Builders, not talkers (Remastered).mp3" plays it in the bottom bar; the fresh Wavlake purchase from step 2 is now listed too.
5. **Dedupe (A5):** browsing several peers, each file appears on ~1 peer; only 3 files (WEBFIVE track, Block Height film, aurora photo) appear on two.
6. **Real photos (A6):** photo cards show real photographs (aurora, Dolomites lake, Osaka neon, dunes, mist, cliffs, Iceland road, Vermont autumn, St Ives, Grindelwald) — no picsum grey placeholders.
7. **Free image lightbox (A8):** clicking any photo card opens the full-screen viewer with backdrop; footer reads "Free · shared by peer"; Save downloads it.
8. **Free audio/video (A7):** clicking "Zazawowow - Michael Michael Saylor.mp3" (free) plays in the bottom bar; a free video (if on the browsed peer) opens the video modal.
9. **Paid gating (A7):** a blurred paid image click opens the pay modal, never the image; paying via the Lightning QR path also opens the content in-app (no orphan browser download).
## Self-Check: PASSED
- demo/content/music/Zazawowow - WEBFIVEFOURTHREETWOONE.mp3 — FOUND (MPEG audio)
- demo/content/music/Zazawowow - Michael Michael Saylor.mp3 — FOUND (MPEG audio)
- demo/peer-media/artwork-webfive.jpg, artwork-michael-saylor.jpg — FOUND (JPEG)
- All 10 demo/peer-media/photo-*.jpg — FOUND (JPEG, >=1920px wide)
- Commit 14d1a453 — FOUND in git log
- Commit f52c5407 — FOUND in git log
@@ -0,0 +1,242 @@
---
phase: quick-260729-je5
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
- neode-ui/src/App.vue
- neode-ui/src/views/RootRedirect.vue
autonomous: true
requirements: [QUICK-JE5-01, QUICK-JE5-02]
must_haves:
truths:
- "Connected-nodes card (dashboard Web5 view): the visible nodes list grows/shrinks to fill the card so the gap above the Find Nodes / Refresh buttons is a constant pt-4, regardless of how tall the sibling Node Visibility card makes the xl 2-col grid row (QUICK-JE5-01)"
- "Below xl (single-column/mobile) the list keeps its current max-h-72 cap — no visual/sizing change (QUICK-JE5-01)"
- "Demo build opened inside the Android companion WebView shows NO typing splash and NO /onboarding/intro — it lands on /login as if the intro was already seen (QUICK-JE5-02)"
- "Browser/PWA demo intro behavior is byte-identical to today (replays on every fresh root boot); the skip path writes NOTHING to localStorage (QUICK-JE5-02)"
- "Non-demo builds are a complete no-op for both changes' runtime behavior (QUICK-JE5-02)"
- "All existing unit tests stay green (697) and npm run build succeeds with the new strings present in the built bundle"
artifacts:
- "neode-ui/src/views/web5/Web5ConnectedNodes.vue (flex/scroll fix, no design change)"
- "neode-ui/src/App.vue (companion+demo intro gate at both IS_DEMO branch sites)"
- "neode-ui/src/views/RootRedirect.vue (companion+demo intro gate at both IS_DEMO branch sites)"
key_links:
- "isCompanionApp() from neode-ui/src/utils/openExternal.ts is the single companion-detection source at all four IS_DEMO intro branch sites (same convention as stores/appLauncher.ts lines 224/329)"
- "Web5ConnectedNodes.vue card root is already `flex flex-col`; the fix works entirely inside that column flex (list = flexible middle, footer = non-shrinking bottom)"
---
<objective>
Two small, isolated UI fixes in neode-ui (shared by real + demo builds):
1. **QUICK-JE5-01 — Connected-nodes list flex fix (both builds):** the scrollable nodes
list in the dashboard "Connected Nodes" card must always end at a consistent margin
above the bottom Find Nodes / Refresh buttons, even when the sibling card
(Web5NodeVisibility) stretches the shared grid row. Today the list is hard-capped at
`max-h-72` and the footer uses `mt-auto`, so a tall sibling opens a growing dead gap
between list and buttons.
2. **QUICK-JE5-02 — Companion app skips demo intro (demo build only):** when the demo
runs inside the Android companion WebView (`window.ArchipelagoNative` bridge
injected), skip the typing splash + `/onboarding/intro` entirely and land on /login,
without touching any state the browser demo relies on. Desktop/PWA browser demo
intro must be completely unaffected — the user resets and relies on it before demos.
Purpose: fix a visible layout bug on every node dashboard, and stop the companion app
from replaying the demo cinematic every time the demo is opened in-app.
Output: 2 focused commits on main (frontend only), tests green, verified build.
</objective>
<context>
@/home/archipelago/Projects/archy/CLAUDE.md
@/home/archipelago/Projects/archy/neode-ui/src/views/web5/Web5ConnectedNodes.vue
@/home/archipelago/Projects/archy/neode-ui/src/utils/openExternal.ts
@/home/archipelago/Projects/archy/neode-ui/src/composables/useDemoIntro.ts
Key facts already established (do not re-derive):
- The "connected nodes" container is `neode-ui/src/views/web5/Web5ConnectedNodes.vue`.
Its card root (line 3) is already `glass-card p-6 ... flex flex-col`. The three tab
panes (Trusted ~line 57, Observers ~line 90, Requests ~line 120) each use
`class="space-y-2 max-h-72 overflow-y-auto"` with `v-show`, and the button footer
(~line 161) is `<div class="mt-auto pt-4 space-y-3">`. The row is
`neode-ui/src/views/web5/Web5.vue` line 59: `grid grid-cols-1 xl:grid-cols-2 gap-6`
with sibling `Web5NodeVisibility` (grid items stretch to row height by default).
- Companion detection already exists: `isCompanionApp()` in
`neode-ui/src/utils/openExternal.ts` (true iff the native shell injected
`window.ArchipelagoNative` with an `openInApp` function; a plain browser/PWA never
has it, and the bridge exists before page scripts run — appLauncher.ts already
relies on it synchronously).
- The demo intro fires from exactly four IS_DEMO branch sites:
- `neode-ui/src/App.vue` ~line 433: `if (IS_DEMO && bootPath === '/') replayRequested = true` (typing splash on every root boot)
- `neode-ui/src/App.vue` ~line 588: post-splash `if (IS_DEMO) { router.push('/onboarding/intro'); reveal(); return }`
- `neode-ui/src/views/RootRedirect.vue` ~lines 82 and 149: `if (IS_DEMO) { demoRoute() }` → pushes `/onboarding/intro`
- `views/web5/__tests__/Web5ConnectedNodes.test.ts` exists but does NOT assert on
`max-h-72` / `mt-auto` classes (verified by grep) — class changes should not break it.
</context>
<tasks>
<task type="auto">
<name>Task 1: Connected-nodes list fills the card — constant gap above footer buttons (QUICK-JE5-01)</name>
<files>neode-ui/src/views/web5/Web5ConnectedNodes.vue</files>
<action>
Implement the column-flex fix per QUICK-JE5-01, exactly as scoped: flexible
scrollable list + non-shrinking footer, replacing the fixed cap as the xl-row
sizing mechanism. Concretely:
1. On EACH of the three v-show tab panes (Trusted, Observers, Requests — the divs
currently classed `space-y-2 max-h-72 overflow-y-auto`), change the classes to:
`space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`
- `flex-auto` (flex: 1 1 auto) + `min-h-0` is the standard fix: the visible pane
becomes the flexible middle of the column-flex card, growing to absorb any
extra height the grid row imposes and shrinking (with internal scroll) when
constrained — so the space between list end and footer is always exactly the
footer's own pt-4.
- Keep `max-h-72` ONLY below xl (`xl:max-h-none` lifts it): below xl the grid is
single-column (`grid-cols-1`), no sibling stretches the card, and the current
mobile sizing must not change (constraint: no visual design change).
- Hidden panes are `v-show` (display:none) so applying flex classes to all three
is safe — only the visible one participates in layout.
2. On the footer div (`mt-auto pt-4 space-y-3`), add `shrink-0` so the buttons can
never be compressed by a long list. Keep `mt-auto` (harmless once the list is
flex-auto — it only matters in the sub-xl capped case, where it preserves today's
behavior exactly).
3. Touch NOTHING else in the component: no color, spacing, typography, or markup
changes. The card root already has `flex flex-col` — do not restructure it.
4. Sanity-check the sibling row in `neode-ui/src/views/web5/Web5.vue` line 59 (read
only): default grid item stretch is what feeds the card its height — no change
needed there.
This component is shared by real and demo builds, so one fix covers both builds.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vitest run src/views/web5/__tests__/Web5ConnectedNodes.test.ts</automated>
Also: grep the component to confirm no pane retains a bare `max-h-72` without `xl:max-h-none`, and that all three panes have `flex-auto min-h-0`.
</verify>
<done>
All three tab panes are `flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none`;
footer has `shrink-0`; component test file passes; no other visual changes.
</done>
</task>
<task type="auto">
<name>Task 2: Companion WebView + demo build skips the intro entirely (QUICK-JE5-02)</name>
<files>neode-ui/src/App.vue, neode-ui/src/views/RootRedirect.vue</files>
<action>
Gate all four IS_DEMO intro branch sites on NOT-companion, per QUICK-JE5-02. Use the
existing `isCompanionApp()` from `@/utils/openExternal` (do NOT invent new
detection — this is the same convention appLauncher.ts uses at lines 224/329, and
the `window.ArchipelagoNative` bridge is injected by the native shell before page
scripts run, so it is safe to call synchronously at boot).
1. `neode-ui/src/App.vue` (~line 433): change
`if (IS_DEMO && bootPath === '/') replayRequested = true`
to also require `!isCompanionApp()` — companion never requests the demo
splash replay.
2. `neode-ui/src/App.vue` (~line 588): gate the post-splash
`if (IS_DEMO) { router.push('/onboarding/intro') ... }` block with
`!isCompanionApp()`. When companion+demo, prefer routing DIRECTLY to '/login'
and `reveal()` (mirroring the "seenOnboarding === true" branch just below)
rather than falling through to `checkOnboardingStatus()` — the mock backend
reports onboarded and would land on /login anyway, but the direct route avoids
the status-check retry ladder and any splash-adjacent behavior. Add a one-line
comment: companion in-app demo skips the intro; browser demo unaffected.
3. `neode-ui/src/views/RootRedirect.vue` (~lines 82 and 149): gate both
`if (IS_DEMO) { demoRoute() }` calls the same way. When IS_DEMO and
isCompanionApp(), route to '/login' (behaving exactly as an intro-already-seen
demo session) instead of demoRoute(). Import `isCompanionApp` from
'@/utils/openExternal' (static import is fine — the module is tiny and already
in the main bundle).
4. HARD invariants (from the task constraints):
- Write NOTHING to localStorage/sessionStorage from any skip path (no
`neode_intro_seen`, no `demo_intro_date`, nothing) — the browser demo's
manually-reset intro state must be untouched.
- Non-demo builds: `IS_DEMO` is false, so every gated branch short-circuits
before `isCompanionApp()` matters — verify by inspection that no new code
runs outside `IS_DEMO === true` paths (keep `isCompanionApp()` on the RIGHT
side of the `&&` / inside the IS_DEMO block).
- Browser/PWA demo: `isCompanionApp()` is false (no bridge) — all four sites
behave byte-identically to today.
5. If an existing unit test covers RootRedirect demo routing, update/extend it; if
cheap, add a small test asserting `isCompanianApp`-style bridge detection drives
the skip (stub `window.ArchipelagoNative = { openInApp: () => {} }`). Do not
build heavy test scaffolding — the 697 existing tests staying green is the gate.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy/neode-ui && npx vitest run</automated>
Full suite green (697+ tests). Then grep both edited files to confirm every
intro-triggering IS_DEMO branch also checks `isCompanionApp()`.
</verify>
<done>
All four IS_DEMO intro branch sites (App.vue x2, RootRedirect.vue x2) skip the
splash/intro and route to /login when `isCompanionApp()` is true; zero storage
writes on the skip path; zero behavior change for browser demo and non-demo builds;
full unit suite green.
</done>
</task>
<task type="auto">
<name>Task 3: Build verification + bundle grep + commits</name>
<files>neode-ui/ (build only — no new source edits expected)</files>
<action>
Per CLAUDE.md "Build / verify" and "Commit & push every unit of work":
1. `cd /home/archipelago/Projects/archy/neode-ui && npm run build` (vue-tsc + vite;
outputs to web/dist/neode-ui). Build must succeed with zero type errors.
2. Bundle grep (build can silently no-op — always grep the built output):
- Fix 1: `grep -rl "xl:max-h-none" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/` must match at least one asset (the new Tailwind class proves the fresh component shipped).
- Fix 2: the gate is inside IS_DEMO code, which a non-demo build may fold away, so
verify against a scratch demo build:
`VITE_DEMO=1 npx vite build --outDir /tmp/claude-1000/-home-archipelago-Projects-archy/3ca40190-d6bb-4f98-9d89-8d2479484065/scratchpad/demo-dist --emptyOutDir`
then confirm a JS chunk contains BOTH the `demoRoute` log string and
`ArchipelagoNative` (heuristic that the companion gate survived into the demo
bundle):
`grep -rl "demoRoute" <scratch>/demo-dist/assets/*.js | xargs grep -l "ArchipelagoNative"`
Do NOT commit or deploy the scratch demo build — it is verification only.
3. Commits (code only — the orchestrator commits .planning docs):
- Commit 1: Web5ConnectedNodes.vue flex fix.
- Commit 2: App.vue + RootRedirect.vue companion demo-intro skip (plus any test
file touched in Task 2).
- Stage EXPLICITLY by path (`git add neode-ui/src/...`), never `git add -A`.
- NEVER stage anything under `indeedhub/` (git submodule) — check
`git status --porcelain` before each commit and confirm no `indeedhub` entries
are staged.
- Do not commit `web/dist/` build output unless the repo already tracks it AND
it changed as a direct product of these fixes (check `git status` — if dist is
untracked/ignored, leave it alone).
- Messages end with the `Co-Authored-By: Claude ...` trailer per CLAUDE.md.
</action>
<verify>
<automated>cd /home/archipelago/Projects/archy/neode-ui && npm run build && grep -rl "xl:max-h-none" ../web/dist/neode-ui/assets/ | head -1</automated>
Plus the demo-build co-occurrence grep from step 2, and `git log --oneline -2`
showing the two focused commits with no indeedhub/ paths in either
(`git show --stat` per commit).
</verify>
<done>
`npm run build` green; both bundle greps confirm the new code is in the built
output; two focused commits exist, each staged by explicit path, no indeedhub/
content, Co-Authored-By trailer present.
</done>
</task>
</tasks>
<verification>
- Full unit suite: `cd neode-ui && npx vitest run` — all tests green (697 baseline; new tests may raise the count, zero failures).
- `npm run build` succeeds; `web/dist/neode-ui` contains `xl:max-h-none`.
- Scratch `VITE_DEMO=1` build contains the companion gate (demoRoute + ArchipelagoNative co-occurrence).
- Manual spot-check (optional, dev preview :8100 or `npm run dev:mock`): on a wide (xl) window, pad the Node Visibility card content tall and confirm the connected-nodes list expands so the buttons keep an unchanged pt-4 gap; on a narrow window the card looks exactly as before.
- Grep confirms no localStorage writes were added in App.vue/RootRedirect.vue skip paths.
</verification>
<success_criteria>
- QUICK-JE5-01: nodes list ends at a constant pt-4 above the bottom buttons at any xl row height; sub-xl sizing unchanged; no visual design changes.
- QUICK-JE5-02: companion WebView + demo lands on /login with no splash and no /onboarding/intro; browser/PWA demo and non-demo builds byte-identical in behavior; no intro-state storage writes from the skip path.
- Tests green, build verified via bundle grep, two clean path-staged commits, nothing from indeedhub/ touched.
</success_criteria>
<output>
On completion create `.planning/quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/260729-je5-SUMMARY.md` (committed by the orchestrator, not the executor).
</output>
@@ -0,0 +1,103 @@
---
phase: quick-260729-je5
plan: 01
subsystem: neode-ui
tags: [web5, layout, demo, companion, onboarding-intro]
requirements: [QUICK-JE5-01, QUICK-JE5-02]
dependency-graph:
requires: []
provides:
- "Connected-nodes card list flexes to fill xl grid-row height (constant pt-4 gap above footer buttons)"
- "Companion WebView + demo build skips typing splash + /onboarding/intro, lands on /login"
affects: [neode-ui demo build, companion app demo UX, Web5 dashboard]
tech-stack:
added: []
patterns:
- "Column-flex scroll pane: flex-auto min-h-0 overflow-y-auto with breakpoint-lifted max-h cap (max-h-72 xl:max-h-none)"
- "isCompanionApp() as the single companion-detection source at IS_DEMO branch sites (same convention as appLauncher.ts)"
key-files:
created:
- neode-ui/src/utils/__tests__/openExternal.test.ts
modified:
- neode-ui/src/views/web5/Web5ConnectedNodes.vue
- neode-ui/src/App.vue
- neode-ui/src/views/RootRedirect.vue
decisions:
- "RootRedirect skip paths deliberately do NOT call log() — log() writes sessionStorage (archipelago_boot_log) and the skip path must write nothing to storage"
- "Cheap test option chosen: unit test for isCompanionApp() bridge detection (the skip's driving mechanism) instead of heavy RootRedirect mount scaffolding"
metrics:
duration: ~15m
completed: 2026-07-29
tasks: 3
tests: "700 passed (697 baseline + 3 new)"
status: complete
---
# Quick Task 260729-je5: Connected-Nodes Scrollable List + Companion Demo Intro Skip Summary
Connected-nodes list now flexes to fill the xl grid-row (constant pt-4 gap above Find Nodes/Refresh) and the Android companion demo skips the intro straight to /login via isCompanionApp() gates at all four IS_DEMO branch sites, with zero storage writes.
## Task Commits
| Task | Name | Commit | Files |
| ---- | ---- | ------ | ----- |
| 1 | Connected-nodes list fills card (QUICK-JE5-01) | `b80e7c34` | Web5ConnectedNodes.vue |
| 2 | Companion+demo intro skip (QUICK-JE5-02) | `d54517cf` | App.vue, RootRedirect.vue, openExternal.test.ts |
| 3 | Build verification + bundle greps | — (verification only, no source edits) | — |
## What Was Done
### QUICK-JE5-01 — Connected-nodes list flex fix (`b80e7c34`)
- All three v-show tab panes (Trusted line 57, Observers line 90, Requests line 120) changed from `space-y-2 max-h-72 overflow-y-auto` to `space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none` — the visible pane is now the flexible middle of the card's existing column flex, growing to absorb row height from a tall sibling Web5NodeVisibility card and scrolling internally when constrained.
- Below xl (single-column grid) the `max-h-72` cap remains — sub-xl sizing byte-identical.
- Footer div gets `shrink-0` (kept `mt-auto`) so the buttons can never be compressed.
- No other markup/design changes; Web5.vue grid row (line 59) untouched as planned.
### QUICK-JE5-02 — Companion demo intro skip (`d54517cf`)
All four IS_DEMO intro branch sites gated on the existing `isCompanionApp()` from `@/utils/openExternal` (static import added to both files):
1. `App.vue` line 435: `if (IS_DEMO && bootPath === '/' && !isCompanionApp()) replayRequested = true` — companion never requests the splash replay; with the mock backend reporting onboarded, `shouldShowIntroSplash` then suppresses the splash.
2. `App.vue` post-splash block (~line 592): companion+demo routes directly to `/login` + `reveal()` (mirrors the seenOnboarding===true branch), avoiding the status-check retry ladder.
3. `RootRedirect.vue` `proceedToApp()` (~line 87): companion+demo → `router.replace('/login')` instead of `demoRoute()`.
4. `RootRedirect.vue` onMounted server-up branch (~line 160): same gate.
**Hard invariants verified:**
- Zero storage writes on any skip path — RootRedirect skip paths intentionally do NOT call `log()` because it writes `sessionStorage.archipelago_boot_log`; diff grep for added `localStorage|sessionStorage` lines matched only a comment.
- Non-demo builds: `isCompanionApp()` sits on the right of `IS_DEMO &&` / inside `if (IS_DEMO)` blocks — never reached when IS_DEMO is false (compile-time false in non-demo builds; demo scratch bundle confirmed dead-code folding of the non-demo path).
- Browser/PWA demo: no bridge → `isCompanionApp()` false → all four sites behave byte-identically (intro replays on every fresh root boot).
New test `src/utils/__tests__/openExternal.test.ts`: 3 cases asserting bridge detection (no bridge → false; bridge with openInApp → true; bridge without callable openInApp → false).
## Verification
- Full unit suite: **700 passed, 0 failed** (697 baseline + 3 new).
- `npm run build` (vue-tsc + vite) green; `web/dist/neode-ui/assets/Web5-CB3C73UV.js` contains `xl:max-h-none` (fix 1 shipped).
- Scratch `VITE_DEMO=1` build (scratchpad only, not committed): the plan's single-chunk co-occurrence grep did not match because Vite splits chunks — `demoRoute` lives in `RootRedirect-*.js` while `ArchipelagoNative` lives in the shared `index-*.js`/`Dashboard-*.js` chunks. Verified semantically instead (stronger): RootRedirect chunk contains both gated sites compiled as `if(E()){_.replace("/login")...;return}x();return` where `E` is imported from the index chunk whose `isCompanionApp` implementation checks `openInApp=="function"` on `ArchipelagoNative`.
- `web/dist` is gitignored — left untouched per plan; no dist output committed.
- Both commits path-staged, submodule guard run before each, no `indeedhub/` paths (`git show --stat` clean), `Co-Authored-By: Claude` trailer present.
- Pre-existing untracked files from other agents (`.planning/phases/01-.../01-PATTERNS.md`, `scripts/resilience/.gitignore-reports.tmp`) left alone.
## Deviations from Plan
### Auto-fixed / adjusted
**1. [Verification method] Demo-bundle co-occurrence grep replaced with per-chunk semantic verification**
- **Found during:** Task 3
- **Issue:** The plan's heuristic (`grep -rl demoRoute ... | xargs grep -l ArchipelagoNative`) assumes both strings land in one JS chunk; Vite's code splitting puts them in different chunks.
- **Fix:** Verified the actual gate in the RootRedirect demo chunk (minified `if(E()){replace("/login")}` at both sites, `E` = isCompanionApp import) and the `openInApp=="function"` detection in the index chunk.
- **Files modified:** none (verification only).
No other deviations — plan executed as written.
## Known Stubs
None.
## Self-Check: PASSED
- FOUND: neode-ui/src/views/web5/Web5ConnectedNodes.vue (3 panes with flex-auto min-h-0 ... xl:max-h-none; footer shrink-0)
- FOUND: neode-ui/src/utils/__tests__/openExternal.test.ts
- FOUND: commit b80e7c34 (Task 1)
- FOUND: commit d54517cf (Task 2)