Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit e3bcd9fca9
2056 changed files with 468069 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Frontend Configuration
# Copy this to .env and adjust as needed
# Backend API URL
VITE_BACKEND_URL=http://localhost:5959
# API base path
VITE_API_BASE=/rpc/v1
# Development mode
VITE_DEV_MODE=true
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
._*
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Backup and temporary video files
**/*-backup-*.mp4
**/*-1.47mb.mp4
**/bg-*.mp4
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+227
View File
@@ -0,0 +1,227 @@
# 🚀 Neode Development Scripts
Quick reference for starting and stopping the Neode development environment.
## Quick Start
### Start Everything (Recommended)
```bash
npm start
# or
./start-dev.sh
```
This will:
- ✅ Check and clean up any processes on ports 5959, 8100-8102
- ✅ Start Docker Desktop if it's not running (waits up to 60 seconds)
- ✅ Start the mock backend (port 5959)
- ✅ Start Vite dev server (port 8100)
- ✅ Display status with color-coded output
**Access the app:**
- **Frontend**: http://localhost:8100
- **Backend RPC**: http://localhost:5959/rpc/v1
- **WebSocket**: ws://localhost:5959/ws/db
**Login credentials:**
- Password: `password123`
### Stop Everything
```bash
npm stop
# or
./stop-dev.sh
```
This will cleanly shut down:
- Mock backend server
- Vite dev server
- All related processes
---
## Individual Commands
### Run Mock Backend Only
```bash
npm run backend:mock
```
### Run Vite Only
```bash
npm run dev
```
### Run Both (without cleanup)
```bash
npm run dev:mock
```
### Run with Real Rust Backend
```bash
# Terminal 1: Start Rust backend
cd ../core
cargo run --release
# Terminal 2: Start Vite
npm run dev:real
```
---
## Troubleshooting
### Port Already in Use
If you see port conflicts, run:
```bash
npm stop
```
Then start again:
```bash
npm start
```
### Kill All Node Processes (Nuclear Option)
```bash
pkill -9 node
```
### Check What's Running on a Port
```bash
# Check port 5959
lsof -i :5959
# Check port 8100
lsof -i :8100
```
### View Logs
If running in background, logs are in:
```bash
tail -f /tmp/neode-dev.log
```
---
## Features
### Mock Backend
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
- **Auto-Detection** - Automatically detects container runtime and adapts
- **WebSocket Support** - Real-time state updates via JSON patches
- **Pre-loaded Apps** - 7 apps always visible in My Apps
### Pre-installed Apps (always running in mock mode)
- `bitcoin` - Bitcoin Core (port 8332)
- `lnd` - Lightning Network Daemon (port 8080)
- `electrs` - Electrum Server in Rust (port 50001)
- `mempool` - Blockchain explorer (port 4080)
- `filebrowser` - Web file manager (port 8083)
- `lorabell` - LoRa doorbell (no UI port)
- `fedimint` - Federated Bitcoin mint (port 8175)
Additional apps can be installed from the Marketplace (30+ available).
---
## Development Workflow
1. **Start servers:**
```bash
npm start
```
2. **Open browser:**
```
http://localhost:8100
```
3. **Login:**
```
password123
```
4. **Make changes** - Vite HMR will reload instantly
5. **Stop servers when done:**
```bash
npm stop
```
---
## Build Commands
### Development Build
```bash
npm run build
```
### Docker Build (no type checking)
```bash
npm run build:docker
```
### Type Check Only
```bash
npm run type-check
```
### Preview Production Build
```bash
npm run preview
```
---
## Script Details
### start-dev.sh
- Checks all required ports (5959, 8100-8102)
- Kills any existing processes on those ports
- Verifies node_modules are installed
- Starts both servers with concurrently
- Handles Ctrl+C gracefully
- Color-coded output for easy reading
### stop-dev.sh
- Finds all Neode-related processes
- Kills by port (5959, 8100-8102)
- Kills by process name (mock-backend, vite, concurrently)
- Confirms each shutdown with status messages
- Color-coded output
---
## Tips
- Always use `npm start` for the cleanest experience
- Run `npm stop` before switching branches if there are backend changes
- Vite will try alternate ports (8101, 8102) if 8100 is busy
- Mock backend simulates 1.5s installation delay for realism
---
## Known Issues
### Node.js Version Warning
```
You are using Node.js 20.18.2. Vite requires Node.js version 20.19+ or 22.12+.
```
**To fix:**
```bash
# Using nvm (recommended)
nvm install 22
nvm use 22
# Or upgrade directly
brew upgrade node
```
The warning is non-fatal - Vite still works, but upgrading is recommended.
---
Happy coding! 🎨⚡
+52
View File
@@ -0,0 +1,52 @@
FROM node:22-alpine
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache wget curl docker-cli
# Copy package files
COPY neode-ui/package*.json ./
# Install Node dependencies (need all deps for mock backend)
RUN npm install
# Copy application code
COPY neode-ui/ ./
# Sibling assets the mock backend reads relative to /app (../docker, ../demo):
# the Bitcoin UI mock shell and the curated cloud files (demo/files drop-ins +
# the committed demo/content library — both are scanned by loadDemoDiskFiles).
COPY docker/bitcoin-ui /docker/bitcoin-ui
COPY docker/electrs-ui /docker/electrs-ui
COPY docker/lnd-ui /docker/lnd-ui
COPY docker/fedimint-ui /docker/fedimint-ui
COPY demo/files /demo/files
COPY demo/content /demo/content
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
# deliberately OUTSIDE demo/content so it doesn't appear as the visitor's own
# cloud files.
COPY demo/peer-media /demo/peer-media
# This image only ever serves the public demo — scrub the private release/registry
# server address from everything it serves (mock data, catalog.json, demo assets)
# and fail the build if any occurrence survives.
RUN find /app /docker /demo -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \
-o -name '*.css' -o -name '*.html' -o -name '*.json' -o -name '*.md' -o -name '*.txt' \
-o -name '*.yml' -o -name '*.yaml' \) -not -path '*/node_modules/*' \
-exec sed -i \
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
if grep -rq '146\.59\.87\.168' /app/mock-backend.js /app/public /docker /demo; then \
echo 'LEAK: release-server IP still in demo image'; exit 1; fi
# Expose port
EXPOSE 5959
# Health check
HEALTHCHECK --interval=30s --timeout=15s --retries=5 --start-period=180s \
CMD wget --quiet --tries=1 --spider http://localhost:5959/health || exit 1
# Start the mock backend with error handling
CMD ["sh", "-c", "node mock-backend.js 2>&1 || (echo 'ERROR: Backend failed to start'; cat /app/package.json; ls -la /app; sleep infinity)"]
+68
View File
@@ -0,0 +1,68 @@
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY neode-ui/package*.json ./
# Install all dependencies (including dev)
RUN npm install
# Copy source code
COPY neode-ui/ ./
# Clean up backup files and large unused assets before build
RUN find public/assets -name "*backup*" -type f -delete || true && \
find public/assets -name "*1.47mb*" -type f -delete || true && \
find public/assets -name "bg-*.mp4" -type f -delete || true
# Build the Vue app (skip type checking, just build)
ENV DOCKER_BUILD=true
ENV NODE_ENV=production
# Public-demo build flag — inlined into the bundle (import.meta.env.VITE_DEMO).
# Enables the per-day intro replay, the "entertoexit" login hint, and other
# demo-only UI affordances. Override with --build-arg VITE_DEMO=0 for a plain build.
ARG VITE_DEMO=1
ENV VITE_DEMO=$VITE_DEMO
# Use npm script which handles build better
RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1)
# Demo builds must not leak the private release/registry server address anywhere
# in the served bundle (JS constants, catalog.json, prose). Replace every
# occurrence with a non-routable placeholder, then fail the build if any slipped
# through. (The companion-QR URL is handled in source — it needs a URL that works.)
RUN if [ "$VITE_DEMO" = "1" ] || [ "$VITE_DEMO" = "true" ]; then \
find dist -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.css' -o -name '*.html' \
-o -name '*.json' -o -name '*.map' -o -name '*.txt' -o -name '*.webmanifest' \) \
-exec sed -i \
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
if grep -rq '146\.59\.87\.168' dist; then echo 'LEAK: release-server IP still in dist'; exit 1; fi; \
fi
# Production stage
FROM nginx:alpine
# Copy built files to nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy AIUI pre-built dist
COPY demo/aiui/ /usr/share/nginx/html/aiui/
# Copy nginx config template and entrypoint
COPY neode-ui/docker/nginx-demo.conf /etc/nginx/nginx.conf.template
COPY neode-ui/docker/docker-entrypoint.sh /docker-entrypoint-custom.sh
RUN chmod +x /docker-entrypoint-custom.sh
# IndeeHub demo sign-in seeder, injected by the :2101 whole-origin proxy
# (nginx-demo.conf). Demo image only — never in real-node artifacts.
COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js
# Expose ports (80 = demo UI, 2101 = IndeeHub whole-origin demo proxy)
EXPOSE 80 2101
# Substitute ANTHROPIC_API_KEY at runtime, then start nginx
ENTRYPOINT ["/docker-entrypoint-custom.sh"]
+310
View File
@@ -0,0 +1,310 @@
# Neode Onboarding Flow
## Complete User Journey (Vue 3)
### 1. **Splash Screen** (First Visit Only)
**Duration**: ~23 seconds (skippable)
#### Sequence:
1. **Alien Terminal Intro** (0-16s)
- Line 1: "Initializing Neode OS..." (typing animation)
- Line 2: "Connecting to distributed network..."
- Line 3: "Loading sovereignty protocols..."
- Line 4: "System ready."
- Green `$` prompts, white text
- Skip button in bottom right
2. **Welcome Message** (16-19s)
- "Welcome to Neode" with typing animation
- Fades in after terminal lines complete
3. **Neode Logo** (19-23s)
- Large "NEODE" SVG logo
- Background image fades in
- Smooth transition
#### Local Storage:
- Sets: `neode_intro_seen = '1'`
- Next visit: Skip splash entirely
---
### 2. **Onboarding Intro**
**Route**: `/onboarding/intro`
#### Content:
- **Neode Logo** at top (large SVG)
- **Heading**: "Welcome to Neode"
- **Subheading**: "Your personal server for a sovereign digital life"
- **Features**:
- 🔒 Self-Sovereign: Own your data and applications completely
- ⚡ Powerful: Run any service with one click
- 🛡️ Private: Tor-first architecture for maximum privacy
- **Button**: "Get Started →"
#### Action:
- Navigates to `/onboarding/options`
---
### 3. **Onboarding Options**
**Route**: `/onboarding/options`
#### Content:
- **Neode Logo** at top
- **Heading**: "Choose Your Setup"
- **Subheading**: "How would you like to get started?"
#### Three Glass Cards:
1. **Fresh Start**
- Icon: Plus symbol
- Description: Set up a new server from scratch
2. **Restore Backup**
- Icon: Upload symbol
- Description: Restore from a previous backup
3. **Connect Existing**
- Icon: Link symbol
- Description: Connect to an existing Neode server
#### Selection:
- Cards have hover effects
- Selected card: Brighter, glowing border
- **Button**: "Continue →" (enabled when option selected)
#### Action:
- Sets: `neode_onboarding_complete = '1'`
- Navigates to `/login`
---
### 4. **Login Page**
**Route**: `/login`
#### Content:
- **Neode Logo** floating above card
- **Glass Card** with:
- Title: "Welcome to Neode"
- Password input field
- Login button
- "Forgot password?" link
#### Auth Flow:
- Submit → Pinia store `login()` action
- Success → Navigate to `/dashboard`
- Error → Show error message in red glass banner
---
### 5. **Dashboard**
**Route**: `/dashboard`
#### Layout:
- **Sidebar** (glass):
- Neode logo at top
- Server name + version
- Navigation menu (Home, Apps, Marketplace, Server, Settings)
- Logout button at bottom
- **Main Content**:
- Dynamic based on route (Home, Apps, etc.)
- Connection status banner (if offline)
- Glass cards throughout
---
## Flow Diagram
```
┌─────────────────┐
│ First Visit? │
└────────┬────────┘
┌────▼────┐
│ Yes │──────┐
└─────────┘ │
│ │
┌────▼────────────▼────┐
│ Splash Screen │ (23s, skippable)
│ - Alien Intro │
│ - Welcome Message │
│ - Neode Logo │
└──────────┬───────────┘
[Sets: neode_intro_seen]
┌──────────▼───────────┐
│ Onboarding Intro │
│ - Logo │
│ - Features │
│ - Get Started │
└──────────┬───────────┘
┌──────────▼───────────┐
│ Onboarding Options │
│ - Fresh Start │
│ - Restore │
│ - Connect │
└──────────┬───────────┘
[Sets: neode_onboarding_complete]
┌──────────▼───────────┐
│ Login Page │
│ - Password Input │
└──────────┬───────────┘
[Authenticate]
┌──────────▼───────────┐
│ Dashboard │
│ - Sidebar + Content │
└──────────────────────┘
```
---
## Returning User Flow
### Second Visit Onwards:
```
Open App
├─ neode_intro_seen? YES
├─ neode_onboarding_complete? YES
└──> Login Page (direct)
```
- **No splash screen**
- **No onboarding**
- Goes straight to `/login`
---
## Local Storage Keys
| Key | Value | Set By | Effect |
|-----|-------|--------|--------|
| `neode_intro_seen` | `'1'` | SplashScreen.vue | Skip splash on return |
| `neode_onboarding_complete` | `'1'` | OnboardingOptions.vue | Skip onboarding on return |
---
## Branding Consistency
### Neode Logo Usage
**SVG Logo** (`/assets/img/logo-large.svg`):
- ✅ Splash screen (large, centered)
- ✅ Onboarding intro (medium, top)
- ✅ Onboarding options (medium, top)
- ✅ Login page (floating above card)
- ✅ Dashboard sidebar (small, top left)
**Icon** (`/assets/img/icon.png`):
- ✅ Browser favicon
- ✅ Apple touch icon
### No Start9 Branding
All Start9 references removed. Pure Neode branding throughout.
---
## Design Consistency
### Glassmorphism
Every screen uses:
- Glass cards with `backdrop-filter: blur(18px)`
- Black background with transparency
- White borders with 18% opacity
- Drop shadows for depth
### Colors
- Background: `rgba(0, 0, 0, 0.35)`
- Text: White with opacity (96%, 80%, 70%)
- Accents: Green `#00ff41` (terminal prompts)
- Borders: `rgba(255, 255, 255, 0.18)`
### Typography
- Primary: Avenir Next
- Mono: Courier New (terminal/splash)
- Size scale: 4px grid system
---
## Testing the Flow
### Test as New User:
```bash
# Clear storage
localStorage.clear()
# Reload
location.reload()
```
**Expected**:
1. Splash → Alien intro → Welcome → Logo
2. Onboarding intro → Features
3. Onboarding options → Select option
4. Login → Enter password
5. Dashboard → Home screen
### Test as Returning User:
```bash
# Storage should have:
localStorage.getItem('neode_intro_seen') // '1'
localStorage.getItem('neode_onboarding_complete') // '1'
# Reload
location.reload()
```
**Expected**:
1. Login (direct, no splash/onboarding)
2. Dashboard → Home screen
---
## Skip Behaviors
### Skip Splash
- Button: "Skip Intro" (bottom right)
- Effect: Jumps to logo display
- Still navigates to onboarding intro
### Skip Onboarding
User can navigate directly to `/login` if they know the URL.
---
## Future Enhancements
- [ ] Different flows for each setup option (Fresh/Restore/Connect)
- [ ] Progress indicators during setup
- [ ] Animated transitions between onboarding steps
- [ ] Video/GIF demos on feature cards
- [ ] Personalization (server name input during onboarding)
- [ ] Setup wizard for advanced users
---
## Key Files
| File | Purpose |
|------|---------|
| `src/App.vue` | Manages splash display, handles completion |
| `src/components/SplashScreen.vue` | Alien intro, animations, skip button |
| `src/views/OnboardingIntro.vue` | Welcome screen, feature highlights |
| `src/views/OnboardingOptions.vue` | Setup method selection |
| `src/views/Login.vue` | Authentication |
| `src/views/Dashboard.vue` | Main app layout |
| `src/router/index.ts` | Route definitions, auth guards |
---
**Complete, cohesive, and beautiful!** 🎨⚡
+154
View File
@@ -0,0 +1,154 @@
# Archipelago Web UI
Vue 3 + TypeScript + Vite + Tailwind CSS + Pinia
The web interface for Archipelago — a self-sovereign Bitcoin Node OS.
## Quick Start
```bash
cd neode-ui
npm install
npm start
```
Visit **http://localhost:8100** — login with password: `password123`
This starts:
- Mock backend on port 5959 (no Docker required)
- Vite dev server on port 8100 with HMR
Stop with `npm stop`.
## Architecture
```
neode-ui/
├── src/
│ ├── api/ # RPC client (rpc-client.ts), WebSocket, container client
│ ├── stores/ # Pinia stores (app, container, appLauncher, monitoring)
│ ├── views/ # Page components (Dashboard, Marketplace, Settings, etc.)
│ ├── components/ # Reusable components (SplashScreen, AppSession, etc.)
│ ├── router/ # Vue Router configuration
│ ├── types/ # TypeScript type definitions
│ └── style.css # Global styles + Tailwind utilities
├── public/assets/ # Static assets (images, fonts, app icons, audio)
├── mock-backend.js # Mock backend server (simulates Rust backend)
├── docker/ # Docker configs (nginx, entrypoint)
└── vite.config.ts # Vite config with backend proxy
```
## Dev Modes
The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
| Mode | Command | Behavior |
|------|---------|----------|
| **default** | `npm start` | Fully set up, login screen |
| **existing** | `VITE_DEV_MODE=existing npm run dev:mock` | Same as default |
| **setup** | `VITE_DEV_MODE=setup npm run dev:mock` | First-time password setup flow |
| **onboarding** | `VITE_DEV_MODE=onboarding npm run dev:mock` | Post-setup onboarding flow |
| **boot** | `npm run dev:boot` | 25s simulated boot sequence |
## Mock Backend
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
**Pre-installed apps** (always visible in My Apps):
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
**Features simulated**:
- Authentication (login, password change, TOTP 2FA)
- System metrics (CPU, memory, disk — randomized for realism)
- Node identity (DID, Nostr pubkey, Tor address)
- Federation (3 mock nodes with apps, metrics, trust levels)
- Mesh networking (4 LoRa peers, encrypted messaging, invoices)
- Peer-to-peer messaging
- FileBrowser API (mock file system with Music, Documents, Photos, Videos)
- DWN sync status
- Transport layer (mesh/LAN/Tor routing)
- Notifications (5 realistic entries)
- Claude AI chat proxy (requires `ANTHROPIC_API_KEY`)
**Container runtime**: If Docker/Podman is available, the mock backend will run real containers for installed apps. Otherwise, it simulates them.
## Demo Deployment (Portainer)
Deploy the demo via Docker Compose for showcasing:
```bash
docker compose -f docker-compose.demo.yml build
docker compose -f docker-compose.demo.yml up -d
```
Or deploy through **Portainer Stacks**:
1. Stacks > Add stack > name: `archy-demo`
2. Web editor: paste `docker-compose.demo.yml` contents
3. Add environment variable: `ANTHROPIC_API_KEY` (for Claude chat)
4. Deploy
Access at **http://your-host:4848** — password: `password123`
## Development Commands
```bash
npm start # Start mock backend + Vite (recommended)
npm stop # Stop all servers
npm run dev:mock # Same as start, without port cleanup
npm run dev:boot # Boot mode (simulated startup delay)
npm run backend:mock # Mock backend only
npm run dev # Vite only (needs backend running separately)
npm run dev:real # Vite with real Rust backend
npm run build # Production build (outputs to ../web/dist/neode-ui/)
npm run build:docker # Build for Docker (no type checking)
npm run type-check # TypeScript type checking
npm test # Run tests
```
## Design System
### Glass Classes
| Class | Use |
|-------|-----|
| `.glass-card` | Content containers, modals, panels |
| `.glass-button` | ALL buttons (primary and secondary) |
| `.path-option-card` | Interactive cards with hover lift |
| `.info-card` | Status badges, metric displays |
### Tokens
- **Font**: Avenir Next (primary), Montserrat (`font-archipelago`)
- **Glass**: `bg: rgba(0,0,0,0.60)`, `blur: 24px`, `border: rgba(255,255,255,0.22)`
- **Accent**: `#fb923c` (Bitcoin orange), `#4ade80` (green), `#ef4444` (red)
- **Text**: `rgba(255,255,255,0.9)` primary, `rgba(255,255,255,0.6)` muted
### Rules
- Global CSS classes in `style.css` only — never inline Tailwind in components
- `.gradient-button` is **banned** — use `.glass-button`
- All components use `<script setup lang="ts">`
## API
```typescript
import { rpcClient } from '@/api/rpc-client'
await rpcClient.login('password')
await rpcClient.startPackage('bitcoin')
const metrics = await rpcClient.getMetrics()
```
State management via Pinia stores. WebSocket patches applied automatically.
## Build Output
- **Dev build**: `../web/dist/neode-ui/`
- **Docker build**: `dist/` (deployed to nginx)
## License
MIT
+39
View File
@@ -0,0 +1,39 @@
# Third-Party Licenses — neode-ui (web frontend)
Runtime dependencies bundled into the distributed web UI. Verified 2026-07-23.
Dev-only tooling (Vite, Playwright, TypeScript, etc.) is not distributed and
is not listed here. Note: the production bundler strips license header
comments, so this file (and the fonts' adjacent license files) constitute the
attribution shipped with the bundle; a build-time license-aggregation step is
planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
| Package | Version | License |
|---|---|---|
| vue | 3.5.x | MIT |
| vue-router | 4.6.x | MIT |
| vue-i18n | 11.3.x | MIT |
| pinia | 3.0.x | MIT |
| d3 | 7.9.x | ISC |
| leaflet | 1.9.x | BSD-2-Clause |
| @vue-leaflet/vue-leaflet | 0.10.x | MIT |
| dompurify | 3.4.x | MPL-2.0 OR Apache-2.0 (used under Apache-2.0) |
| buffer | 6.0.x | MIT |
| fast-json-patch | 3.1.x | MIT |
| fuse.js | 7.1.x | Apache-2.0 |
| qrcode | 1.5.x | MIT |
| qr-scanner | 1.4.x | MIT |
| qrloop | 1.4.x | MIT |
## Fonts
| Font | License | License file |
|---|---|---|
| Montserrat | SIL OFL 1.1 | public/assets/fonts/Montserrat/OFL.txt |
| Open Sans | Apache-2.0 | public/assets/fonts/Open_Sans/LICENSE.txt |
## Vendored
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
(game-icons.net CC BY 3.0; pixelarticons MIT).
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
see ATTRIBUTION.md in that directory.
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A to B Bitcoin - Neode</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
overflow: hidden;
}
.container {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header h1 {
color: white;
font-size: 20px;
font-weight: 600;
}
.badge {
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.4);
color: #86efac;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
iframe {
flex: 1;
border: none;
background: white;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
color: white;
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🅰️➡️🅱️ A to B Bitcoin</h1>
<div class="badge">Running on Neode</div>
</div>
<iframe
id="atobFrame"
src="https://app.atobitcoin.io"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
server {
listen 80;
server_name atob;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Proxy to actual ATOB if needed (or serve local iframe)
location /api {
proxy_pass https://app.atobitcoin.io;
proxy_set_header Host app.atobitcoin.io;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Copy nginx config template
cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
# Ensure client_max_body_size 0 is present (unlimited uploads)
# This is a safety net in case the config template was cached without the directive
if ! grep -q 'client_max_body_size' /etc/nginx/nginx.conf; then
sed -i 's/http {/http {\n client_max_body_size 0;/' /etc/nginx/nginx.conf
fi
exec nginx -g 'daemon off;'
+58
View File
@@ -0,0 +1,58 @@
/**
* PUBLIC-DEMO-ONLY IndeeHub sign-in seeder.
*
* Served at /__demo/indee-demo-signin.js on the :2101 IndeeHub whole-origin
* demo proxy (see nginx-demo.conf) and injected into the proxied site's HTML
* <head> via sub_filter. It only ever runs on the demo's :2101 origin inside
* the demo iframe — it never ships in real-node artifacts.
*
* THROWAWAY DEMO IDENTITY — NOT A SECRET. The embedded secp256k1 keypair was
* freshly generated for the public demo (2026-07-29) and has never belonged
* to any real user. Its whole purpose is to be a shared, public "demo
* visitor" Nostr identity so the embedded IndeeHub boots signed in with no
* login wall. Anyone extracting this key can only impersonate the demo
* visitor, by design (threat T-gjd-01: accepted).
*
* How it works: IndeeHub's bundle (applesauce-accounts) restores accounts on
* boot from localStorage key "indeedhub-accounts" (JSON array of serialized
* accounts; a "nsec" private-key account deserializes as
* { id, type: "nsec", pubkey, metadata, signer: { key: <hex sk> } }) and
* activates the account whose id is stored under "indeedhub-active-account".
* This classic script executes before the SPA's deferred module bundle, so
* seeding here is visible to that boot-restore. Seeding is idempotent: an
* existing non-empty account list is never overwritten.
*/
;(function () {
'use strict'
var ACCOUNTS_KEY = 'indeedhub-accounts'
var ACTIVE_KEY = 'indeedhub-active-account'
// Throwaway demo keypair (see header — public by design, not a secret).
var DEMO_SK_HEX = 'ce2ffa96f99968beffc789cbba5d8b52f4a3020454dcaf77c2b553961bf5a8c9'
var DEMO_PK_HEX = '7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31'
var DEMO_ACCOUNT_ID = 'archy-demo-visitor'
try {
var existing = null
try {
existing = JSON.parse(localStorage.getItem(ACCOUNTS_KEY))
} catch (e) {
existing = null
}
if (Array.isArray(existing) && existing.length > 0) return
var account = {
id: DEMO_ACCOUNT_ID,
type: 'nsec',
pubkey: DEMO_PK_HEX,
metadata: { name: 'Archy Demo' },
signer: { key: DEMO_SK_HEX },
}
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify([account]))
localStorage.setItem(ACTIVE_KEY, DEMO_ACCOUNT_ID)
} catch (e) {
// localStorage unavailable (e.g. blocked third-party storage) — the demo
// visitor just sees IndeeHub's normal signed-out state.
}
})()
+226
View File
@@ -0,0 +1,226 @@
worker_processes 1;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 768;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Allow large uploads globally (filebrowser, etc.)
client_max_body_size 0;
# WebSocket upgrade passthrough (mempool live data, etc.)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Proxy API requests to backend
location /rpc/v1 {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket connections
location /ws {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Proxy public assets from backend
location /public {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy REST API requests
location /rest {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ElectrumX UI status (polled by the electrs-ui shell)
location /electrs-status {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# LND UI endpoints (polled by the lnd-ui shell)
location /proxy/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /lnd-connect-info {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy FileBrowser API to mock backend (demo mode)
# ^~ on every /app/ prefix: the .css/.js/.img cache regex below must
# never swallow app-shell assets (they live on the backend, not in the
# web root — without ^~ nginx prefers the regex and 404s them).
location ^~ /app/filebrowser/ {
client_max_body_size 10G;
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_request_buffering off;
}
# IndeeHub is NOT proxied under a /app/indeedhub/ path prefix — the
# old sub_filter path-rewrite approach broke the SPA's runtime-built
# absolute-root asset URLs. Instead, a dedicated WHOLE-ORIGIN reverse
# proxy of https://indee.tx1138.com listens on :2101 (see the second
# server block below): the SPA sees itself at '/' so every asset and
# router path just works, framing headers are stripped, and a demo
# sign-in seed script is injected. useDemoIntro.demoAppUrl points the
# in-app iframe at http://<demo-host>:2101/.
# Mempool is NOT proxied upstream anymore — the mock backend serves a
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
# so /app/mempool/ falls through to the generic /app/ location below.
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
# generic "Not available in the demo" notice for the rest.
location ^~ /app/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Serve AIUI SPA
location /aiui/ {
alias /usr/share/nginx/html/aiui/;
try_files $uri $uri/ =404;
location ~* /aiui/assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Proxy AIUI API requests (web-search, etc.) to backend
location /api/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy Ollama (local AI) requests to backend
location /aiui/api/ollama/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_set_header Connection "";
}
# Proxy Claude API requests to backend (which handles API key + streaming)
location /aiui/api/claude/ {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_set_header Connection "";
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets (media too — the intro video/audio are versioned
# with ?v=N query busters, so immutable is safe)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# ── IndeeHub whole-origin demo proxy (:2101) ────────────────────────────
# Pure reverse proxy of the LIVE https://indee.tx1138.com site on its own
# port — no path prefix, no URL rewriting, so the SPA's absolute-root
# asset/router paths work untouched. The upstream's X-Frame-Options
# (SAMEORIGIN) and any CSP are stripped so the demo can embed it in the
# in-app iframe session, and a PUBLIC-DEMO-ONLY sign-in seed script is
# injected into the HTML <head> (it seeds a labelled throwaway demo Nostr
# account into the :2101 origin's isolated localStorage, so IndeeHub boots
# signed in). Upstream is pinned to a single fixed hostname — this cannot
# be used as an open proxy.
server {
listen 2101;
server_name _;
# Demo sign-in seeder, served same-origin to the proxied SPA.
location = /__demo/indee-demo-signin.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-store";
}
location / {
proxy_pass https://indee.tx1138.com;
proxy_ssl_server_name on;
proxy_ssl_name indee.tx1138.com;
proxy_set_header Host indee.tx1138.com;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
# Allow embedding in the demo's iframe session.
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# HTML injection: upstream must not compress or sub_filter no-ops.
# (sub_filter applies to text/html by default — exactly what we want.)
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';
}
}
}
+77
View File
@@ -0,0 +1,77 @@
worker_processes 1;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 768;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html index.htm;
# Proxy API requests to backend
location /rpc/v1 {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket connections
location /ws {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Proxy public assets from backend
location /public {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy REST API requests
location /rest {
proxy_pass http://neode-backend:5959;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+660
View File
@@ -0,0 +1,660 @@
# Gamepad Navigation Map
Every arrow key, every position, every page.
`[C]` = Container (red tile, D-pad grid)
`[N]` = Nav bar item (secondary, reached via Up from top row)
`[Y]` = Inner control (entered via Enter on container, exited via Escape)
`[S]` = Sidebar item
---
## Sidebar (all pages)
Vertical list. Up/Down wrap. Right enters page. Left does nothing.
| Position | Up | Down | Right | Left |
|------------|------------|------------|----------------|---------|
| Home | Logout | Apps | First [C] | nothing |
| Apps | Home | Cloud | First [C] | nothing |
| Cloud | Apps | Mesh | First [C] | nothing |
| Mesh | Cloud | Network | First [C] | nothing |
| Network | Mesh | Web5 | First [C] | nothing |
| Web5 | Network | Fleet | First [C] | nothing |
| Fleet | Web5 | Settings | First [C] | nothing |
| Settings | Fleet | AIUI | First [C] | nothing |
| AIUI | Settings | Logout | First [C] | nothing |
| Logout | AIUI | Home | First [C] | nothing |
---
## HOME `/dashboard`
### Nav bar `[N]`
```
[N] Dashboard [N] Setup
```
### Grid `[C]`
```
Row 1: [C] My Apps [C] Cloud
Row 2: [C] Network [C] Wallet
Row 3: [C] System
Row 4: [C] Quick Start (full-width, if visible)
```
| Position | Up | Down | Left | Right | Enter |
|---------------|--------------|--------------|------------|----------|---------------------------|
| [N] Dashboard | nothing | My Apps | nothing | Setup | Switch tab |
| [N] Setup | nothing | My Apps | Dashboard | nothing | Switch tab |
| My Apps | [N] bar | Network | Sidebar | Cloud | /dashboard/apps |
| Cloud | [N] bar | Wallet | My Apps | nothing | /dashboard/cloud |
| Network | My Apps | System | Sidebar | Wallet | /dashboard/server |
| Wallet | Cloud | nothing | Network | nothing | /dashboard/web5 |
| System | Network | Quick Start | Sidebar | nothing | /dashboard/settings |
| Quick Start | System | nothing | Sidebar | nothing | Drill into [Y] |
### Quick Start `[Y]` inner controls
```
[Y] Open a Shop [Y] Accept Payments [Y] File Browser
```
| Position | Left | Right | Escape |
|------------------|------------------|------------------|----------------|
| Open a Shop | nothing | Accept Payments | Back to [C] |
| Accept Payments | Open a Shop | File Browser | Back to [C] |
| File Browser | Accept Payments | nothing | Back to [C] |
---
## APPS `/dashboard/apps`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] All [N] Bitcoin [N] Social (etc) | [N] Search
```
Three groups: page tabs, category filters (dynamic), search input.
| Position | Up | Down | Left | Right | Enter |
|----------------|---------|---------|----------------|----------------|--------------------|
| [N] My Apps | nothing | App1 | nothing | App Store | Switch tab |
| [N] App Store | nothing | App1 | My Apps | Services | /dashboard/discover|
| [N] Services | nothing | App1 | App Store | All filter | Switch tab |
| [N] All | nothing | App1 | Services | Bitcoin (etc) | Filter |
| [N] Search | nothing | App1 | last filter | nothing | Type text |
### Grid `[C]` (3-col)
```
Row 1: [C] App1 [C] App2 [C] App3
Row 2: [C] App4 [C] App5 [C] App6
(etc)
```
| Position | Up | Down | Left | Right | Enter |
|----------------|------------------|-----------|-----------|----------|-----------------|
| App1 (row 1) | [N] bar My Apps | App4 | Sidebar | App2 | Launch app |
| App2 (row 1) | [N] bar My Apps | App5 | App1 | App3 | Launch app |
| App3 (row 1) | [N] bar My Apps | App6 | App2 | nothing | Launch app |
| App4 (row 2) | App1 | App7 | Sidebar | App5 | Launch app |
| App5 (row 2) | App2 | App8 | App4 | App6 | Launch app |
| App6 (row 2) | App3 | App9 | App5 | nothing | Launch app |
| (etc) | above | below | left/side | right | Launch app |
### App `[Y]` inner controls (if no launch action)
```
[Y] Stop [Y] Restart [Y] Uninstall
```
Escape exits back to [C] app card.
---
## CLOUD `/dashboard/cloud`
No nav bar.
### Grid `[C]` (3-col)
```
Row 1: [C] Photos [C] Music [C] Documents
Row 2: [C] Files [C] Peer1 [C] Peer2 (etc)
```
| Position | Up | Down | Left | Right | Enter |
|-------------|------------|-----------|-----------|------------|-------------------|
| Photos | nothing | Files | Sidebar | Music | Open section |
| Music | nothing | Peer1 | Photos | Documents | Open section |
| Documents | nothing | Peer2 | Music | nothing | Open section |
| Files | Photos | nothing | Sidebar | Peer1 | Open section |
| Peer1 | Music | nothing | Files | Peer2 | Open peer files |
| Peer2 | Documents | nothing | Peer1 | nothing | Open peer files |
---
## NETWORK `/dashboard/server`
No nav bar.
### Grid `[C]`
```
Row 1: [C] Quick Actions (full-width, contains Restart/Check Tor/Auto-Sync/Logs)
Row 2: [C] Local Network [C] Web3
Row 3: [C] Network Interfaces [C] Tor Services
```
| Position | Up | Down | Left | Right | Enter |
|----------------------|-----------------|---------------------|---------------|-------------------|------------------|
| Quick Actions | nothing | Local Network | Sidebar | nothing | Drill into [Y] |
| Local Network | Quick Actions | Network Interfaces | Sidebar | Web3 | Drill into [Y] |
| Web3 | Quick Actions | Tor Services | Local Network | nothing | Drill into [Y] |
| Network Interfaces | Local Network | nothing | Sidebar | Tor Services | Drill into [Y] |
| Tor Services | Web3 | nothing | Net Interfaces| nothing | Drill into [Y] |
---
## WEB5 `/dashboard/web5`
No nav bar. Containers from child components stacked vertically + side-by-side.
### Grid `[C]`
```
Row 1: [C] Action1 [C] Action2 [C] Action3 [C] Action4 [C] Action5 [C] Action6
Row 2: [C] Wallet [C] Domains
Row 3: [C] Nostr Relays [C] Node Visibility
Row 4: [C] Connected Nodes
```
Standard spatial grid nav. Left from leftmost = Sidebar. Enter = drill into [Y] controls.
---
## DISCOVER `/dashboard/discover`
### Nav bar `[N]`
```
[N] My Apps [N] App Store [N] Services | [N] Discover [N] Categories... | [N] Search
```
Down from nav bar → first container. Nav bar remembers last-focused tab — Up from cards returns to it.
### Grid `[C]`
```
Featured (2-col): [C] Featured1 [C] Featured2
All Apps (3-col): [C] App1 [C] App2 [C] App3
[C] App4 [C] App5 [C] App6
(etc)
```
Cards use same style as My Apps: `glass-card transition-all hover:-translate-y-1`.
| Position | Up | Down | Left | Right | Enter |
|--------------|----------------|----------|-----------|------------|--------------------|
| [N] tabs | nothing | Featured1| left tab | right tab | Switch/filter |
| Featured1 | remembered [N] | App1 | Sidebar | Featured2 | View details |
| App1 | Featured1 | App4 | Sidebar | App2 | Install / details |
| (etc) | above | below | left/side | right | Install / details |
---
## MESH `/dashboard/mesh`
### Grid `[C]`
```
Left column: [C] Device Status [C] Actions [C] Peers List
Right column: [C] Chat Panel [C] Tools (Bitcoin/Dead Man/Map)
```
| Position | Up | Down | Left | Right | Enter |
|-----------------|---------------|-----------|-----------|-------------|--------------------------------|
| Device Status | nothing | Actions | Sidebar | Chat Panel | Drill into [Y] |
| Actions | Device Status | Peers | Sidebar | Chat Panel | Drill into [Y] buttons |
| Peers List | Actions | nothing | Sidebar | Chat Panel | Drill into peer rows |
| Chat Panel | nothing | Tools | Device | nothing | Drill into [Y] |
| Tools | Chat Panel | nothing | Peers | nothing | Drill into [Y] |
**Chat flow:** Select a peer/channel (Enter on peer row) → focus auto-jumps to message input → type → Enter sends.
---
## FLEET `/dashboard/fleet`
### Grid `[C]`
```
Row 1: [C] Nodes [C] Online [C] Offline [C] Health
Row 2: [C] Node1 [C] Node2 [C] Node3 (etc)
```
Spatial grid nav. Enter = view node details.
---
## SETTINGS `/dashboard/settings`
**Mixed page:** Two containers ([C] Server Name, [C] Interface Mode) + linear buttons.
Up/Down steps through elements. Right navigates paired items on the same row. Left → sidebar.
Enter on containers → drill in. Enter on buttons → activate. Escape → exit container / sidebar.
`[C]` = Container `[B]` = Button `[I]` = Input `[T]` = Toggle
### Account Section (glass-card)
```
1. [C] Server Name → Enter: edit name, Enter: save, Escape: cancel
[B] What's New → right of Server Name
2. [B] Copy DID
3. [B] Copy Onion Address
4. [B] Change Password → opens modal
5. [B] Enable 2FA / Disable 2FA → opens modal
6. [B] Logout
```
### System Section
```
7. [C] Interface Mode → Enter: drill in, Left/Right between Easy/Gamer/Chat, Enter: select, Escape: exit
[B] Language buttons → below Interface Mode
8. [B] Login with Claude → opens modal
9. [T] Enable All (AI data) + per-category [T] toggles
10. [B] Manage Updates
11. [I] Webhook URL
12. [I] Webhook Secret
13. [T] Container Crash [T] Update Available
14. [T] Disk Space Warning [T] Backup Complete
15. [B] Save Configuration [B] Send Test
16. [T] Enable Beta Telemetry
17. [B] Create Backup
18. [B] Export Channel Backup
19. [B] Network Diagnostics → navigates to /dashboard/server
20. [B] Reboot → opens confirm modal
21. [B] Factory Reset → opens confirm modal
```
| Position | Up | Down | Left | Right | Enter |
|---------------------------|-------------|-------------|---------------|----------------|--------------------|
| 1. Server Name | nothing | Copy DID | Sidebar | What's New | Edit name |
| 1b. What's New | nothing | Copy DID | Server Name | nothing | Show release notes |
| 2. Copy DID | Server Name | Copy Onion | Sidebar | nothing | Copy to clipboard |
| 3. Copy Onion | Copy DID | Change PW | Sidebar | nothing | Copy to clipboard |
| 4. Change Password | Copy Onion | Enable 2FA | Sidebar | nothing | Open modal |
| 5. Enable 2FA | Change PW | Logout | Sidebar | nothing | Open modal |
| 6. Logout | Enable 2FA | Language | Sidebar | nothing | Logout |
| 7. Language | Logout | Claude Login| Sidebar | nothing | Select language |
| 8. Login with Claude | Language | AI toggles | Sidebar | nothing | Open modal |
| 9. AI toggles (each row) | above | below | Sidebar | next toggle | Toggle on/off |
| 10. Manage Updates | AI toggles | Webhook URL | Sidebar | nothing | Open updates |
| 11. Webhook URL | Updates | Secret | Sidebar | nothing | Edit field |
| 12. Secret | Webhook URL | Crash toggle| Sidebar | nothing | Edit field |
| 13a. Container Crash | Secret | Disk Space | Sidebar | Update Avail | Toggle on/off |
| 13b. Update Available | Secret | Backup Done | Container Crash| nothing | Toggle on/off |
| 14a. Disk Space Warning | Crash | Save Config | Sidebar | Backup Done | Toggle on/off |
| 14b. Backup Complete | Update Avail| Send Test | Disk Space | nothing | Toggle on/off |
| 15a. Save Configuration | Disk Space | Telemetry | Sidebar | Send Test | Save |
| 15b. Send Test | Backup Done | Telemetry | Save Config | nothing | Send test webhook |
| 16. Telemetry | Save/Test | Create Bkup | Sidebar | nothing | Toggle on/off |
| 17. Create Backup | Telemetry | Export Chan | Sidebar | nothing | Open modal |
| 18. Export Channel | Create Bkup | Net Diag | Sidebar | nothing | Export |
| 19. Network Diagnostics | Export Chan | Reboot | Sidebar | nothing | → /dashboard/server|
| 20. Reboot | Net Diag | Factory Rst | Sidebar | nothing | Open confirm |
| 21. Factory Reset | Reboot | nothing | Sidebar | nothing | Open confirm |
---
## LOGIN `/login`
No sidebar, no grid. Three modes on the same route.
`[B]` = Button `[I]` = Input field `[L]` = Link
### Set Password (first visit after onboarding)
Auto-focus: `[I] Password`
```
[I] Password
[I] Confirm Password
[B] Set Password
[L] Replay Intro [L] Restart Onboarding
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|---------------------|---------------------|-------------------|---------------------|--------------------|
| [I] Password | nothing | [I] Confirm | nothing | nothing | Type / Down |
| [I] Confirm | [I] Password | [B] Set Password | nothing | nothing | Type / Down |
| [B] Set Password | [I] Confirm | [L] Replay Intro | nothing | nothing | Submit |
| [L] Replay Intro | [B] Set Password | nothing | nothing | [L] Restart | Replay intro |
| [L] Restart | [B] Set Password | nothing | [L] Replay Intro | nothing | Restart onboarding |
### Normal Login
Auto-focus: `[I] Password`
```
[I] Password
[B] Login
[L] Replay Intro [L] Restart Onboarding
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|------------------|------------------|-------------------|---------------------|---------------|
| [I] Password | nothing | [B] Login | nothing | nothing | Type / Down |
| [B] Login | [I] Password | [L] Replay Intro | nothing | nothing | Submit |
| [L] Replay Intro | [B] Login | nothing | nothing | [L] Restart | Replay intro |
| [L] Restart | [B] Login | nothing | [L] Replay Intro | nothing | Restart |
### TOTP Verification (after password accepted)
Auto-focus: `[I] TOTP Code`
```
[I] TOTP Code
[B] Verify
[L] Use Backup Code
```
| Position | Up | Down | Left | Right | Enter |
|-----------------------|------------------|------------------|---------|---------|--------------------|
| [I] TOTP Code | nothing | [B] Verify | nothing | nothing | Type / Down |
| [B] Verify | [I] TOTP Code | [L] Backup Code | nothing | nothing | Submit |
| [L] Use Backup Code | [B] Verify | nothing | nothing | nothing | Toggle backup mode |
---
## ONBOARDING `/onboarding/*`
No sidebar, no grid. Sequential wizard screens.
`[B]` = Button `[I]` = Input field `[C]` = Selectable card `[L]` = Link
**Global onboarding rules:**
- No sidebar or nav bar on any onboarding screen.
- First interactive element auto-focused on each screen (inputs when present, otherwise primary button).
- B button (Escape) = go back to previous onboarding step (where applicable).
- D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. Up/Down exits a focused input to the adjacent element.
- Enter on an input = submit if it's the last field, otherwise move to next field.
- Enter activates the focused element.
---
### INTRO `/onboarding/intro`
Default focus: `[B] Unlock`
```
[B] Unlock your sovereignty
[L] Restore from backup
```
| Position | Up | Down | Left | Right | Enter |
|-------------------|-----------------|-----------------|---------|---------|------------------------------|
| [B] Unlock | nothing | [L] Restore | nothing | nothing | → /onboarding/path |
| [L] Restore | [B] Unlock | nothing | nothing | nothing | Show restore panel |
#### Restore Panel `[Y]` (shown after activating Restore link)
```
[I] File picker
[I] Passphrase
[B] Cancel [B] Restore
```
| Position | Up | Down | Left | Right | Enter | Escape |
|-------------------|-----------------|-----------------|------------|------------|--------------------|----------------|
| [I] File picker | nothing | [I] Passphrase | nothing | nothing | Open file dialog | Close panel |
| [I] Passphrase | [I] File picker | [B] Cancel | nothing | nothing | Type / Down | Close panel |
| [B] Cancel | [I] Passphrase | nothing | nothing | [B] Restore| Close panel | Close panel |
| [B] Restore | [I] Passphrase | nothing | [B] Cancel | nothing | Submit restore | Close panel |
---
### PATH `/onboarding/path`
Default focus: `[C] Fresh Start`
```
[C] Fresh Start [C] Restore (disabled) [C] Connect (disabled)
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|-----------------|---------------|-------------------|-------------------|------------------------|
| [C] Fresh Start | nothing | [B] Continue | nothing | [C] Restore | Select option |
| [C] Restore | nothing | [B] Continue | [C] Fresh Start | [C] Connect | nothing (disabled) |
| [C] Connect | nothing | [B] Continue | [C] Restore | nothing | nothing (disabled) |
| [B] Continue | [C] Fresh Start | nothing | nothing | nothing | → /login (complete) |
---
### OPTIONS `/onboarding/options`
Default focus: `[C] Sovereignty`
```
Row 1: [C] Sovereignty [C] Commerce [C] Projects
Row 2: [C] Transmitter [C] Hoster [C] AI
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|------------------|------------------|--------------------|
| [C] Sovereignty | nothing | [C] Transmitter | nothing | [C] Commerce | nothing (display) |
| [C] Commerce | nothing | [C] Hoster | [C] Sovereignty | [C] Projects | nothing (display) |
| [C] Projects | nothing | [C] AI | [C] Commerce | nothing | nothing (display) |
| [C] Transmitter | [C] Sovereignty | [B] Continue | nothing | [C] Hoster | nothing (display) |
| [C] Hoster | [C] Commerce | [B] Continue | [C] Transmitter | [C] AI | nothing (display) |
| [C] AI | [C] Projects | [B] Continue | [C] Hoster | nothing | nothing (display) |
| [B] Continue | [C] Transmitter | nothing | nothing | nothing | → /onboarding/did |
---
### DID `/onboarding/did`
**Loading state:** No interactive elements. Auto-advances when generation completes.
**After generation:**
Default focus: `[B] Continue`
```
[B] Copy DID
[B] Copy Nostr (if available)
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|---------|---------|-----------------------------|
| [B] Copy DID | nothing | [B] Copy Nostr | nothing | nothing | Copy to clipboard |
| [B] Copy Nostr | [B] Copy DID | [B] Continue | nothing | nothing | Copy to clipboard |
| [B] Continue | [B] Copy Nostr | nothing | nothing | nothing | → /onboarding/identity |
If no Nostr ID: `[B] Copy DID` → Down → `[B] Continue` directly.
---
### IDENTITY `/onboarding/identity`
Auto-focus: `[I] Name`
```
[I] Identity Name
[C] Personal [C] Business [C] Anonymous
[B] Continue
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|-----------------|-----------------|-----------------------------|
| [I] Name | nothing | [C] Personal | nothing | nothing | Type / Down |
| [C] Personal | [I] Name | [B] Continue | nothing | [C] Business | Select purpose |
| [C] Business | [I] Name | [B] Continue | [C] Personal | [C] Anonymous | Select purpose |
| [C] Anonymous | [I] Name | [B] Continue | [C] Business | nothing | Select purpose |
| [B] Continue | [C] Personal | nothing | nothing | nothing | → /onboarding/backup |
---
### BACKUP `/onboarding/backup`
Auto-focus: `[I] Passphrase`
```
[I] Passphrase
[B] Download Backup
[B] Continue (disabled until downloaded)
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|------------------|------------------|---------|---------|-----------------------------|
| [I] Passphrase | nothing | [B] Download | nothing | nothing | Type / Down |
| [B] Download | [I] Passphrase | [B] Continue | nothing | nothing | Create & download backup |
| [B] Continue | [B] Download | nothing | nothing | nothing | → /onboarding/verify |
`[B] Continue` disabled (skip focus) until backup downloaded.
---
### VERIFY `/onboarding/verify`
**Phase 1 — Signing:**
Default focus: `[B] Sign Challenge`
```
[B] Sign Challenge
```
| Position | Up | Down | Left | Right | Enter |
|----------------------|---------|---------|---------|---------|------------------------|
| [B] Sign Challenge | nothing | nothing | nothing | nothing | Sign crypto challenge |
**Phase 2 — After verification:**
Default focus: `[B] Finish`
```
[B] Finish
```
| Position | Up | Down | Left | Right | Enter |
|-------------|---------|---------|---------|---------|------------------------------|
| [B] Finish | nothing | nothing | nothing | nothing | → /onboarding/done |
---
### DONE `/onboarding/done`
Default focus: `[B] Set Password`
```
[C] Identity [C] Backup [C] Ready
[B] Set Password
```
| Position | Up | Down | Left | Right | Enter |
|---------------------|--------------|------------------|---------------|---------------|----------------------|
| [C] Identity | nothing | [B] Set Password | nothing | [C] Backup | nothing (display) |
| [C] Backup | nothing | [B] Set Password | [C] Identity | [C] Ready | nothing (display) |
| [C] Ready | nothing | [B] Set Password | [C] Backup | nothing | nothing (display) |
| [B] Set Password | [C] Identity | nothing | nothing | nothing | → /login |
---
## Onboarding & Login Rules
1. No sidebar or nav bar — linear wizard flow.
2. First interactive element auto-focused (input fields when present, otherwise primary button).
3. D-pad Up/Down **always** moves between focusable elements — inputs are never trapping. You can always D-pad out of a focused field.
4. Left/Right for horizontal card rows only.
5. Disabled elements are skipped in focus order.
6. B button (Escape) navigates back one onboarding step.
7. Enter on input: submits if last field, otherwise advances to next field.
8. No wrap — edges are dead stops.
9. No dead ends — every screen has a forward action.
---
## Rules
1. Sidebar: Up/Down wrap. Right → first [C]. Left → nothing.
2. Grid: arrows move between [C] spatially. No wrap at edges.
3. Left from leftmost [C] → Sidebar active tab.
4. Up from top-row [C] → [N] nav bar (if page has one), else nothing.
5. Enter on [C]: has link → navigate. No link → drill into [Y].
6. Inside [Y]: arrows move between inner controls. Escape → back to [C].
7. Escape from [C] → Sidebar.
8. No dead ends.
---
## Implementation Notes (for future sessions)
### Key files
- **Navigation logic**: `neode-ui/src/composables/useControllerNav.ts`
- **Controller store**: `neode-ui/src/stores/controller.ts`
- **Nav sounds**: `neode-ui/src/composables/useNavSounds.ts`
- **Focus styles**: `neode-ui/src/style.css` (lines ~53-142, search `focus-visible`)
### Data attributes
| Attribute | Purpose |
|-----------|---------|
| `data-controller-zone="main"` | Main content area (`<main>` in Dashboard.vue) |
| `data-controller-zone="sidebar"` | Sidebar nav |
| `data-controller-container` + `tabindex="0"` | Focusable card tile — gamepad can land on it, Enter drills in |
| `data-controller-install` | Container has Install button (Enter prioritizes it) |
| `data-controller-launch` | Container has Launch button (Enter prioritizes it) |
| `data-controller-install-btn` | The actual Install button inside a container |
| `data-controller-launch-btn` | The actual Launch button inside a container |
| `data-controller-ignore` | Skip element and descendants from gamepad nav |
| `tabindex="-1"` | Remove from gamepad focus order (used on ToggleSwitch) |
### Focus memory keys
| Key | Purpose | Cleared on |
|-----|---------|------------|
| `sidebar` | Last sidebar item focused | never (persists) |
| `main` | Last container/element in main zone | route change |
| `navBar` | Last nav bar tab (for Up return from containers) | route change |
### Navigation handler order (handleKeyDown)
1. **Text inputs** — special handling (Enter submits, Up/Down exits field)
2. **Escape** — close overlays → exit inner controls → exit to sidebar → back on detail pages
3. **Enter** — container actions (install/launch/link/inner) → regular click
4. **Sidebar** — Up/Down wrap, Right → main (containers or first focusable)
5. **Inside container** — arrows move between inner controls, can't leave via arrows
6. **Nav bar items** — Left/Right between tabs, Down/Up to nearest focusable (containers + buttons)
7. **Main zone** — spatial nav through containers + standalone focusables, fallbacks for edges
### Mixed pages (containers + standalone buttons, e.g. Settings)
- `isNavBarItem()` returns false on container-free pages (lets main zone handler do linear nav)
- Both nav bar handler and main zone handler search containers + standalone focusables together
- This prevents "jumping" where Down skips standalone buttons to reach the next container
- The filter `el.hasAttribute('data-controller-container') || !el.closest('[data-controller-container]')` excludes inner buttons
### Container-free pages (e.g. Settings if all containers removed)
- Sidebar → Right: checks `zone.querySelector('[data-controller-container]')` — if none found, focuses first focusable immediately (no 1s poll delay)
- `isNavBarItem()` returns false (prevents nav bar handler from catching everything)
- Main zone handler's spatial nav through all focusables handles Up/Down/Left/Right
### ToggleSwitch component
- Has `tabindex="-1"` and `data-controller-ignore` — invisible to gamepad nav
- Parent button handles the toggle click, so the switch doesn't need its own focus
- Without this, nav gets stuck bouncing between parent button and toggle switch
### Focus glow styles (Chromium gotchas)
- `box-shadow: 0 0 0 Npx` (spread-based ring) does NOT follow `border-radius` on composited layers (`translateZ(0)`)
- `outline` doesn't follow `border-radius` in Chrome < 94
- Safe approach: use blurred `box-shadow` (`0 0 6px 2px`) or `border-color` change for focus rings
- All `[data-controller-container]` have `outline: none !important` to kill browser defaults
- Cards use `glass-card transition-all hover:-translate-y-1` for consistent hover/focus lift
### Mesh chat auto-focus
- `openChat()`, `openChannelChat()`, `openArchChannel()` all call `nextTick(() => chatInputEl.value?.focus())`
- Message input has `@keydown.enter.exact.prevent="handleSendMessage"` — Enter sends immediately
- Ref: `chatInputEl` on the `<input>` element in Mesh.vue
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Page } from '@playwright/test'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const APP_ID = process.env.ARCHY_APP_ID ?? 'lnd'
const APP_TITLE = process.env.ARCHY_APP_TITLE ?? APP_ID
const APP_CARD_TITLE = process.env.ARCHY_APP_CARD_TITLE ?? APP_TITLE
const EXPECTED_URL = process.env.ARCHY_EXPECTED_LAUNCH_URL
const EXPECTED_URL_PATTERN = process.env.ARCHY_EXPECTED_LAUNCH_URL_PATTERN
const EXPECTED_BODY_PATTERN = process.env.ARCHY_EXPECTED_BODY_PATTERN ?? 'Connect Your Wallet|lndconnect|REST|gRPC'
const EXPECTED_MODE = process.env.ARCHY_EXPECTED_LAUNCH_MODE ?? 'popup'
async function login(page: Page) {
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]').first().click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
test('installed app launch opens reachable app URL', async ({ page, context, baseURL }) => {
test.skip(!EXPECTED_URL, 'Set ARCHY_EXPECTED_LAUNCH_URL for launch qualification')
await login(page)
await page.goto('/dashboard/apps', { waitUntil: 'domcontentloaded' })
const appCard = page.locator('[data-controller-container]', {
has: page.getByRole('heading', { name: APP_CARD_TITLE, exact: true }),
}).first()
await appCard.waitFor({ timeout: 30_000 })
const launchButton = appCard.locator('[data-controller-launch-btn], button:has-text("Launch")').first()
await launchButton.waitFor({ timeout: 20_000 })
if (EXPECTED_MODE === 'panel') {
await launchButton.click()
const expected = new URL(EXPECTED_URL!, baseURL)
const frameSelector = `iframe[src^="${expected.toString().replace(/\/$/, '')}"]`
await expect(page.locator(frameSelector).first()).toBeVisible({ timeout: 20_000 })
const frame = page.frameLocator(frameSelector).first()
await expect(frame.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 30_000 })
return
}
const popupPromise = context.waitForEvent('page', { timeout: 15_000 })
await launchButton.click()
const popup = await popupPromise
await popup.waitForLoadState('domcontentloaded', { timeout: 20_000 })
assertLaunchUrl(popup.url(), baseURL)
await expect(popup.locator('body')).toContainText(new RegExp(EXPECTED_BODY_PATTERN, 'i'), { timeout: 20_000 })
})
function assertLaunchUrl(actual: string, baseURL: string | undefined) {
if (EXPECTED_URL_PATTERN) {
expect(actual).toMatch(new RegExp(EXPECTED_URL_PATTERN))
} else {
const expected = new URL(EXPECTED_URL!, baseURL)
expect(actual).toBe(expected.toString())
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* End-to-end verification of the FIRST-VISIT cinematic:
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
* → Archipelago logo) → onboarding intro → login (video background, finale
* armed) → dashboard full reveal (background zoom + interface assembly +
* oomph) → welcome typing — and that a SECOND login stays deliberately
* low-key.
*
* Run against the local demo stack:
* DEMO=1 node mock-backend.js (port 5959)
* VITE_DEMO=1 npx vite --port 8100
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
*
* Audio can't be heard headless, so every HTMLMediaElement.play() and
* WebAudio oscillator start is recorded into window.__audioLog via an init
* script — the assertions check the right cues fired at the right phases.
*/
import { test, expect, type Page } from '@playwright/test'
// Real Chrome (new headless): the bundled headless-shell has no working video
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
// meaningless there. Requires Google Chrome installed.
test.use({ channel: 'chrome' })
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
declare global {
interface Window {
__audioLog: string[]
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
__revealSeen: { zoom?: boolean; glass?: boolean }
}
}
async function instrumentAudioAndVideo(page: Page) {
await page.addInitScript(() => {
window.__audioLog = []
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
// The dashboard reveal classes live only ~8s; a slow run can burn that
// window between two sequential expect() polls. Record their appearance
// the moment it happens instead, and let the test assert on the record.
window.__revealSeen = {}
new MutationObserver(() => {
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
const origPlay = HTMLMediaElement.prototype.play
HTMLMediaElement.prototype.play = function (...args) {
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
if (this.tagName === 'VIDEO') {
const v = this as HTMLVideoElement
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
}
return origPlay.apply(this, args)
}
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
const OrigOsc = OscillatorNode.prototype.start
OscillatorNode.prototype.start = function (...args) {
window.__audioLog.push('osc-start')
return OrigOsc.apply(this, args)
}
})
}
async function freshVisit(page: Page) {
await page.goto(BASE + '/')
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
await page.goto(BASE + '/')
}
/** Walk the full splash from tap-to-start through completion (real time). */
async function runSplash(page: Page, { skip }: { skip: boolean }) {
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
// on click, so don't wait for post-click actionability)
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
// Phase 2: alien typing begins (first line types out)
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
.toBeVisible({ timeout: 10_000 })
if (skip) {
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
} else {
// Let all four lines type out for real (~20s)
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
}
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
// text is only on screen ~6s, so verify the phases via durable signals:
// the video's live health here, and the audio log (speech/song) after.
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
// Wait for actual smooth playback (cold-cache buffering right after mount
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
await page.waitForFunction(() => {
const v = document.querySelector('video')
return !!v && v.readyState >= 3 && v.currentTime > 0.3
}, { timeout: 20_000 })
const s1 = await page.evaluate(() => {
const v = document.querySelector('video')!
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
await page.waitForTimeout(2_000)
const s2 = await page.evaluate(() => {
const v = document.querySelector('video')
if (!v) return null
const q = v.getVideoPlaybackQuality?.()
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
})
expect(s2).not.toBeNull()
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
// Steady-state frame drops over the sample window (startup catch-up excluded).
const dTotal = s2!.total - s1.total
const dDropped = s2!.dropped - s1.dropped
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
// Splash completes → demo routes to the onboarding intro
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
}
async function enterDemoAndLogin(page: Page) {
// The CTA unmounts mid-click when the router transitions away — dispatch
// once, swallow the detach retry, and trust the URL change instead.
const cta = page.getByRole('button', { name: /Enter the demo/ })
await cta.waitFor({ timeout: 15_000 })
await Promise.all([
page.waitForURL('**/login', { timeout: 15_000 }),
cta.click({ noWaitAfter: true }).catch(() => {}),
])
// Demo prefills the password; the finale flag must be armed at this point.
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
const loginBtn = page.getByRole('button', { name: /log ?in/i })
await loginBtn.waitFor({ timeout: 10_000 })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
}
test.describe('first-visit cinematic', () => {
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
test.setTimeout(240_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: false })
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
// FULL first-entry reveal: big background zoom + glass assembly classes
// (recorded by the init-script observer the instant they appear — the
// classes only live ~8s and sequential polling can miss the window).
await expect
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
.toMatchObject({ zoom: true, glass: true })
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
// The reveal runs 8s, then the zoom layer class clears.
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
// The dashboard oomph is WebAudio oscillators — at least the login pop +
// oomph layers must have started after the splash's own sounds.
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
expect(oscCount).toBeGreaterThan(0)
})
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
const log = await page.evaluate(() => window.__audioLog.join('|'))
expect(log).toContain('welcome-noderunner.mp3')
expect(log).toContain('cosmic-updrift.mp3')
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
})
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
test.setTimeout(180_000)
await instrumentAudioAndVideo(page)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
await expect
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
.toBe(true)
// End the session the way logout does (auth token only — the intro/login
// flags survive) and revisit login directly. An authenticated /login visit
// would just bounce back to the dashboard via the router guard.
await page.evaluate(() => localStorage.removeItem('neode-auth'))
// Direct /login navigation (no splash — not a root boot) and re-login.
await page.goto(BASE + '/login')
await page.locator('#login-password').waitFor({ timeout: 15_000 })
// First login happened → static rotated background, not the video.
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
await Promise.all([
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
])
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
// reset with the /login reload, so it would have caught even a transient
// reveal since then.
await page.waitForTimeout(1500)
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
})
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
test.setTimeout(120_000)
await freshVisit(page)
await runSplash(page, { skip: true })
await enterDemoAndLogin(page)
// Reload at root = a fresh boot → the splash must return even though
// neode_intro_seen is now set.
await page.goto(BASE + '/')
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
})
test('video is warmed before the splash needs it', async ({ page }) => {
await freshVisit(page)
// The warm-up is a detached <video preload=auto> kept on window (Chromium
// has no <link rel=preload as=video>). Give it a moment to buffer.
await expect
.poll(async () => page.evaluate(() => {
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
return w ? { src: w.src, readyState: w.readyState } : null
}), { timeout: 15_000 })
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
const ready = await page.evaluate(() =>
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
})
})
@@ -0,0 +1,530 @@
// keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B.
//
// Standalone, re-runnable Playwright spec that logs into the deployed
// a test node build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
// performs a visit -> away (to the neutral /dashboard/settings tab) ->
// return round trip, reporting whether the component instance survived.
//
// Deliberately does NOT edit measure.ts / surfaces.ts / surface-perf.spec.ts
// — the 02-01 harness stays frozen so 02-10 can re-run it unmodified. This
// spec reuses SURFACES' navSteps/contentSelector/rootSelector (read-only
// import) as the click recipe to reach each tab, but implements its own
// corrected stamp/read method plus three instruments the ad-hoc 02-08 probe
// did not have:
//
// 1. A monotonic per-mount signal written by the page itself, independent
// of the DOM-element dataset stamp: Vue unconditionally attaches
// `el.__vueParentComponent` to every mounted root element (confirmed
// by reading node_modules/@vue/runtime-core's `mountElement`, not
// gated behind a dev-only flag), whose `.uid` is a per-instance
// monotonic counter. Reading this before/after the round trip gives an
// INSTANCE-identity signal that can disagree with the dataset-mark's
// ELEMENT-identity signal if the probe's selector picks a different
// cached instance than the one actually under test (suspect 1).
// 2. `page.on('pageerror')` / `page.on('console')` captured for the whole
// round trip and printed — a Server-specific runtime error during
// activation/deactivation (suspect 2) would surface here even though
// it might not throw synchronously enough to fail the Playwright step
// itself.
// 3. After each hop: `document.querySelectorAll('.view-container').length`
// and `location.pathname`, so cache population and the actual route
// path are visible in the transcript (suspects 3 and 4).
//
// Corrected remount method (documented in 02-FINDINGS.md's ## Results
// preamble): stamp/read the `.view-container`-class ANCESTOR (or the
// surface's own `rootSelector`, for the two surfaces — Mesh, Chat — that
// don't use `.view-container`) of the surface's own VISIBLE contentSelector
// match, using `getBoundingClientRect`/`offsetParent` to exclude KeepAlive's
// inactive cached instances — never `document.querySelector`'s first DOM
// match, which can silently pick a different cached instance once multiple
// `.view-container`s coexist in the document at once.
import { expect, test, type Page } from '@playwright/test'
import { SURFACES, type Surface } from './surfaces'
// Re-declared rather than imported from '@/views/dashboard/keepAliveRoutes':
// the e2e package's tsconfig (see tsconfig.app.json's `include`) does not
// cover `e2e/**`, so the `@/*` path alias used across neode-ui/src is not
// guaranteed to resolve under Playwright's own TS transform. surfaces.ts and
// measure.ts already avoid the alias for the same reason (no `@/` import in
// either file) — this list is the exact literal from keepAliveRoutes.ts
// (TAB_ORDER minus withheld `/dashboard/settings`, plus `/dashboard/discover`).
const KEEP_ALIVE_PATHS = new Set<string>([
'/dashboard',
'/dashboard/apps',
'/dashboard/marketplace',
'/dashboard/cloud',
'/dashboard/mesh',
'/dashboard/server',
'/dashboard/web5',
'/dashboard/fleet',
'/dashboard/chat',
'/dashboard/discover',
])
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
const NAV_TIMEOUT = 20_000
const CONTENT_TIMEOUT = 20_000
async function login(page: Page): Promise<void> {
// Mirrors surface-perf.spec.ts's login() (itself mirroring app-launch.spec.ts)
// verbatim — do not invent a second auth path.
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
.first()
.click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
interface HopSnapshot {
viewContainerCount: number
pathname: string
}
async function snapshotHop(page: Page): Promise<HopSnapshot> {
return page.evaluate(() => ({
viewContainerCount: document.querySelectorAll('.view-container').length,
pathname: location.pathname,
}))
}
/**
* Wait until the DOM has stopped churning: the raw element count for
* `contentSelector` (NOT filtered by visibility — some contentSelectors,
* e.g. `.home-card`, legitimately match several sibling cards at once, so
* "exactly 1" is the wrong invariant) reads identically across 5 consecutive
* 100ms-spaced polls.
*
* Discovered mid-investigation (not hypothesized up front): `/dashboard/settings`
* — the away tab EVERY round trip in this probe (and in measure.ts's own
* `NEUTRAL_SELECTOR` convention) uses — renders `AccountInfoSection.vue` and
* `KioskDisplaySection.vue` unconditionally (Settings.vue has no tabs/
* accordion gating), and BOTH carry `data-controller-container`. Settings'
* own root ALSO gets the `view-container` fallthrough class (every non-full-
* bleed route does). That means Settings' OWN content matches the exact
* generic `.view-container [data-controller-container]` selector Server,
* Web5 and Fleet all use — and Settings' leave-transition keeps its DOM
* genuinely present (in the document, still counted by querySelectorAll)
* for the transition's full duration while the RETURN target's enter-
* transition is already progressing. A stamp/read taken during that overlap
* window can silently pick the still-present-but-leaving Settings element
* instead of the actual target — a false "remounted" verdict that has
* nothing to do with KeepAlive at all. Waiting for the raw count to settle
* lets the leave-transition finish (Vue's <Transition> removes the leaving
* element from the DOM once its leave hook completes) before this probe
* stamps or reads anything.
*/
async function waitForDomSettled(page: Page, contentSelector: string, timeoutMs: number): Promise<void> {
await page.evaluate((sel) => {
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
w.__probeStableCount = 0
w.__probeLastLen = document.querySelectorAll(sel).length
}, contentSelector)
await page.waitForFunction(
(sel) => {
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
const len = document.querySelectorAll(sel).length
if (w.__probeLastLen === len) {
w.__probeStableCount = (w.__probeStableCount ?? 0) + 1
} else {
w.__probeStableCount = 0
}
w.__probeLastLen = len
return (w.__probeStableCount ?? 0) >= 5
},
contentSelector,
{ timeout: timeoutMs, polling: 100 }
)
}
interface ProbeReading {
ok: boolean
uid: number | null
typeName: string | null
}
/** Locate the VISIBLE contentSelector match's rootSelector ancestor (or
* self, if contentSelector === rootSelector) and stamp it with `mark`,
* recording the Vue instance uid/type-name found on it at the same moment. */
async function stampVisibleRoot(page: Page, contentSelector: string, rootSelector: string, mark: string): Promise<ProbeReading> {
return page.evaluate(
({ contentSelector, rootSelector, mark }) => {
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
const visible = candidates.find((el) => {
const rect = el.getBoundingClientRect()
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
})
if (!visible) return { ok: false, uid: null, typeName: null }
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
if (!root) return { ok: false, uid: null, typeName: null }
root.dataset.perfProbeMark = mark
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const comp = (root as any).__vueParentComponent ?? null
const uid: number | null = comp ? (comp.uid ?? null) : null
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
return { ok: true, uid, typeName }
},
{ contentSelector, rootSelector, mark }
)
}
interface RootMarkDebug {
mark: string | null
visible: boolean
connected: boolean
}
interface ReadResult {
ok: boolean
markMatches: boolean
uid: number | null
typeName: string | null
allRootMarks: RootMarkDebug[]
/** The authoritative "what does the user actually see" signal:
* `document.elementFromPoint()` at the viewport center performs real hit
* testing (respects stacking/z-index/opacity), unlike the
* `offsetParent`/`getBoundingClientRect` heuristic used above, which
* cannot distinguish the true foreground root from another root that
* merely has non-zero layout dimensions while stacked behind it. */
elementFromPointMark: string | null
}
async function readVisibleRoot(
page: Page,
contentSelector: string,
rootSelector: string,
expectedMark: string
): Promise<ReadResult> {
return page.evaluate(
({ contentSelector, rootSelector, expectedMark }) => {
// Diagnostic: every element matching rootSelector ANYWHERE in the
// document (not just the one reachable from the visible content
// match), reporting its own mark + visibility. If the ORIGINAL
// stamped root still carries its mark but is not the one this read
// considers "visible", that is a completely different finding
// (a still-alive-but-orphaned cached instance) than the mark being
// gone from every root entirely (a genuine destroy+recreate).
const allRoots = Array.from(document.querySelectorAll(rootSelector)) as HTMLElement[]
const allRootMarks = allRoots.map((el) => {
const rect = el.getBoundingClientRect()
return {
mark: el.dataset.perfProbeMark ?? null,
visible: el.offsetParent !== null && (rect.width > 0 || rect.height > 0),
connected: el.isConnected,
}
})
// Authoritative real-hit-test signal, independent of the
// offsetParent/rect heuristic above.
const cx = Math.floor(window.innerWidth / 2)
const cy = Math.floor(window.innerHeight / 2)
const topEl = document.elementFromPoint(cx, cy) as HTMLElement | null
const topRoot = (topEl?.closest(rootSelector) as HTMLElement | null) ?? null
const elementFromPointMark = topRoot?.dataset.perfProbeMark ?? null
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
const visible = candidates.find((el) => {
const rect = el.getBoundingClientRect()
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
})
if (!visible) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
if (!root) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const comp = (root as any).__vueParentComponent ?? null
const uid: number | null = comp ? (comp.uid ?? null) : null
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
return { ok: true, markMatches: root.dataset.perfProbeMark === expectedMark, uid, typeName, allRootMarks, elementFromPointMark }
},
{ contentSelector, rootSelector, expectedMark }
)
}
interface RoundTripResult {
path: string
label: string
/** Primary verdict: did the SAME element survive the round trip (the
* corrected 02-08 method's own signal)? null = could not be probed. */
elementSurvived: boolean | null
/** Independent instance-identity signal (instrument 1): did the SAME Vue
* component instance (by internal uid) survive? null = could not be read
* (e.g. __vueParentComponent absent, or the view was never found). */
instanceSurvived: boolean | null
instanceTypeNameBeforeAway: string | null
instanceTypeNameAfterReturn: string | null
/** Diagnostic: every rootSelector-matching element in the document at
* read-back time, with its own mark + visibility/connected state — shows
* whether an unmatched original root is genuinely gone vs still present
* (just not the one the visibility filter picked). */
allRootMarksAtRead: RootMarkDebug[]
/** Authoritative real-hit-test signal at read-back time (see ReadResult's
* own doc comment) — null means either the read failed or elementFromPoint
* found no rootSelector ancestor at the viewport center. */
elementFromPointMarkAtRead: string | null
elementFromPointSurvived: boolean | null
afterVisit: HopSnapshot | null
afterAway: HopSnapshot | null
afterReturn: HopSnapshot | null
consoleMessages: string[]
pageErrors: string[]
error: string | null
}
/**
* Best-effort dismissal of a stray full-screen overlay before it blocks the
* next click — mirrors measure.ts's own `dismissOverlays()` (this spec
* deliberately does not import from measure.ts, so the logic is duplicated
* here rather than shared, per the "don't edit the frozen 02-01 harness"
* constraint). a test node currently runs at 85% disk (02-FINDINGS.md
* Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast
* live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper
* has no `pointer-events: none`, so it silently intercepts clicks on
* whatever sits behind it — an environmental condition unrelated to the
* KeepAlive remount question this probe exists to answer, and one this
* probe must route around rather than be blocked by.
*/
async function dismissOverlays(page: Page): Promise<void> {
try {
await page.keyboard.press('Escape')
} catch {
// no-op — best-effort only
}
const closeButtons = page.locator(
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
)
const count = await closeButtons.count().catch(() => 0)
if (count > 0) {
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
}
}
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
const attempts = 3
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
await dismissOverlays(page)
try {
await page.locator(selector).first().click({ timeout: perAttemptMs })
return
} catch (err) {
lastErr = err
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
async function goHome(page: Page): Promise<void> {
if (new URL(page.url()).pathname === '/dashboard/chat') {
await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).catch(() => {})
}
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', NAV_TIMEOUT)
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: NAV_TIMEOUT })
}
async function clickChain(page: Page, steps: string[]): Promise<void> {
for (const selector of steps) {
await clickWithGuard(page, selector, NAV_TIMEOUT)
}
}
async function roundTrip(page: Page, surface: Surface): Promise<RoundTripResult> {
const consoleMessages: string[] = []
const pageErrors: string[] = []
const onConsole = (msg: { type: () => string; text: () => string }) => {
consoleMessages.push(`[${msg.type()}] ${msg.text()}`)
}
const onPageError = (err: Error) => {
pageErrors.push(err.message)
}
page.on('console', onConsole)
page.on('pageerror', onPageError)
let afterVisit: HopSnapshot | null = null
let afterAway: HopSnapshot | null = null
let afterReturn: HopSnapshot | null = null
let stampReading: ProbeReading = { ok: false, uid: null, typeName: null }
let readReading: ReadResult = { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks: [], elementFromPointMark: null }
try {
await goHome(page)
await clickChain(page, surface.navSteps)
// Wait for the URL first, THEN the content selector: three of these ten
// surfaces (Server, Web5, Fleet) share the generic
// `.view-container [data-controller-container]` contentSelector
// (02-FINDINGS.md's own documented ambiguity), so waiting on the
// selector alone can resolve instantly against the PREVIOUS tab's still-
// visible content before the navigation actually lands — a probe
// artifact this instrumentation (pathname logging, instrument 3) caught
// directly rather than one I could have caught by inspection alone.
if (!surface.path.includes(':')) {
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
}
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
afterVisit = await snapshotHop(page)
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
stampReading = await stampVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
if (surface.closeSelector) {
// Deliberately NOT routed through clickWithGuard: dismissOverlays()
// treats any "Close"-labelled dialog button as a stray overlay to
// dismiss, which is exactly this element for a modal-trigger surface
// — same reasoning as measure.ts's own runOnce().
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
} else {
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
}
afterAway = await snapshotHop(page)
await clickChain(page, surface.navSteps)
if (!surface.path.includes(':')) {
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
}
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
afterReturn = await snapshotHop(page)
readReading = await readVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
const elementSurvived = stampReading.ok && readReading.ok ? readReading.markMatches : null
const instanceSurvived =
stampReading.ok && readReading.ok && stampReading.uid != null && readReading.uid != null
? stampReading.uid === readReading.uid
: null
const elementFromPointSurvived = readReading.elementFromPointMark != null ? readReading.elementFromPointMark === mark : null
return {
path: surface.path,
label: surface.label,
elementSurvived,
instanceSurvived,
instanceTypeNameBeforeAway: stampReading.typeName,
instanceTypeNameAfterReturn: readReading.typeName,
allRootMarksAtRead: readReading.allRootMarks,
elementFromPointMarkAtRead: readReading.elementFromPointMark,
elementFromPointSurvived,
afterVisit,
afterAway,
afterReturn,
consoleMessages,
pageErrors,
error: null,
}
} catch (err) {
return {
path: surface.path,
label: surface.label,
elementSurvived: null,
instanceSurvived: null,
instanceTypeNameBeforeAway: stampReading.typeName,
instanceTypeNameAfterReturn: readReading.typeName,
allRootMarksAtRead: readReading.allRootMarks,
elementFromPointMarkAtRead: readReading.elementFromPointMark,
elementFromPointSurvived: null,
afterVisit,
afterAway,
afterReturn,
consoleMessages,
pageErrors,
error: err instanceof Error ? err.message : String(err),
}
} finally {
page.off('console', onConsole)
page.off('pageerror', onPageError)
}
}
test('keepalive-remount-probe: every KEEP_ALIVE_PATHS surface survives a tab round-trip (or reports why it could not be probed)', async ({ page }) => {
test.setTimeout(10 * 60 * 1000)
await login(page)
// Session-wide capture, in addition to roundTrip()'s own per-surface
// listeners: a delayed/async error (e.g. a promise that settles after a
// round trip's own listeners are already detached, mid-flight when we've
// moved on to the next surface) would otherwise be silently missed and
// wrongly attributed to "no error" for the surface actually responsible.
const sessionLog: string[] = []
const sessionStart = Date.now()
const onSessionConsole = (msg: { type: () => string; text: () => string }) => {
sessionLog.push(`[+${Date.now() - sessionStart}ms] [console:${msg.type()}] ${msg.text()}`)
}
const onSessionPageError = (err: Error) => {
sessionLog.push(`[+${Date.now() - sessionStart}ms] [pageerror] ${err.message}`)
}
page.on('console', onSessionConsole)
page.on('pageerror', onSessionPageError)
// De-duplicate by path, keeping the FIRST matching SURFACES row: two rows
// share `path: '/dashboard'` (the `home` main-tab row and the `wallet-send`
// modal-trigger row, which records Home's own path only for reference,
// per surfaces.ts's own doc comment on `closeSelector`) — the modal is not
// itself a KEEP_ALIVE_PATHS-registered route, so only `home` should count.
const seenPaths = new Set<string>()
const probeSurfaces = SURFACES.filter((s) => {
if (!KEEP_ALIVE_PATHS.has(s.path) || seenPaths.has(s.path)) return false
seenPaths.add(s.path)
return true
})
expect(probeSurfaces.length).toBe(KEEP_ALIVE_PATHS.size)
const results: RoundTripResult[] = []
for (const surface of probeSurfaces) {
const result = await roundTrip(page, surface)
results.push(result)
// eslint-disable-next-line no-console
console.log(
`[keepalive-remount-probe] ${result.path} (${result.label}): ` +
`elementSurvived=${result.elementSurvived} instanceSurvived=${result.instanceSurvived} ` +
`typeName(before/after)=${result.instanceTypeNameBeforeAway}/${result.instanceTypeNameAfterReturn} ` +
`viewContainerCount(visit/away/return)=${result.afterVisit?.viewContainerCount ?? 'n/a'}/${result.afterAway?.viewContainerCount ?? 'n/a'}/${result.afterReturn?.viewContainerCount ?? 'n/a'} ` +
`pathname(visit/return)=${result.afterVisit?.pathname ?? 'n/a'}/${result.afterReturn?.pathname ?? 'n/a'} ` +
`elementFromPointSurvived=${result.elementFromPointSurvived} ` +
`pageErrors=${result.pageErrors.length} error=${result.error ?? 'none'}`
)
if (result.pageErrors.length > 0) {
// eslint-disable-next-line no-console
console.log(`[keepalive-remount-probe] pageErrors: ${JSON.stringify(result.pageErrors)}`)
}
if (result.elementSurvived === false) {
// eslint-disable-next-line no-console
console.log(`[keepalive-remount-probe] allRootMarksAtRead (${result.path}): ${JSON.stringify(result.allRootMarksAtRead)}`)
}
}
page.off('console', onSessionConsole)
page.off('pageerror', onSessionPageError)
// eslint-disable-next-line no-console
console.log(`[keepalive-remount-probe] full session log (${sessionLog.length} entries):\n${sessionLog.join('\n')}`)
// eslint-disable-next-line no-console
console.log(`[keepalive-remount-probe] full results JSON:\n${JSON.stringify(results, null, 2)}`)
// Structural assertion only — every registered path must have been
// attempted and produce a result row, mirroring surface-perf.spec.ts's own
// sole assertion (`expect(results.length).toBe(SURFACES.length)`). Whether
// each one SURVIVED, or could even be probed at all, is the finding this
// probe exists to produce, not a pass/fail gate on the spec itself: Mesh's
// device-not-reporting-connected condition and Chat's AIUI-connection
// timing are both pre-existing, environment-dependent blockers
// 02-FINDINGS.md's own `## Results` section already documents as
// "unmeasured" rather than "failed" — an errored sample is recorded, never
// discarded and never used to fail the harness itself, exactly like
// `measure.ts`'s `measureSurface()` treats its own per-run errors.
expect(results.length).toBe(probeSurfaces.length)
})
+387
View File
@@ -0,0 +1,387 @@
// measureSurface() — first-visit vs revisit timing, RPC request trace, and a
// remount probe for one SURFACES row (02-01-PLAN.md Task 1).
//
// Design notes (see surfaces.ts header for the navigation rationale):
// - Navigation between surfaces always happens via real UI clicks
// (RouterLink/button), never `page.goto()`, so a revisit exercises actual
// Vue Router client-side transitions rather than a full page reload.
// - The remount probe stamps `rootSelector`'s DOM node with a unique value
// right after first-visit paints, then reads it back after the away/back
// round-trip. A surviving value means the component instance was reused
// (no remount); a missing/changed value means it was destroyed and
// recreated.
// - RPC calls are traced via `page.on('request')`/`requestfinished`,
// filtered to the `/rpc/v1` endpoint the rpc-client posts to. Only method
// name + timing are recorded — no request/response bodies (T-02-06).
// - `maxConcurrentRpc` / `rpcWallClockMs` are derived from the recorded
// calls' [start, start+duration] intervals via a sweep-line, so a serial
// waterfall (maxConcurrentRpc === 1, 2+ calls) is distinguishable from an
// already-parallel fan-out without re-reading application source.
import type { Page, Request } from '@playwright/test'
import type { Surface } from './surfaces'
const RPC_PATH = '/rpc/v1'
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
const DEFAULT_NAV_TIMEOUT = 20_000
const DEFAULT_CONTENT_TIMEOUT = 20_000
export interface RpcCall {
method: string
/** Wall-clock offset (ms) from the start of the measured phase. */
startedAtMs: number
/** Request duration in ms (null if it never finished/failed to resolve timing). */
durationMs: number | null
}
export interface SurfaceSample {
firstVisitMs: number | null
revisitMs: number | null
firstVisitRpcCount: number | null
revisitRpcCount: number | null
revisitRpcCalls: RpcCall[]
maxConcurrentRpc: number | null
rpcWallClockMs: number | null
/** true = component instance was reused across the away/back round-trip
* (no remount); false = it was destroyed and recreated; null = not probed
* (e.g. the run errored before the probe could run). */
remounted: boolean | null
error: string | null
}
export interface SurfaceMeasurement {
id: string
label: string
path: string
kind: Surface['kind']
runs: number
samples: SurfaceSample[]
/** Median across successful samples (null if every sample errored). */
firstVisitMs: number | null
revisitMs: number | null
firstVisitRpcCount: number | null
revisitRpcCount: number | null
/** RPC call trace from the sample nearest the median revisitMs (or the
* last successful sample if no median could be computed). */
revisitRpcCalls: RpcCall[]
maxConcurrentRpc: number | null
rpcWallClockMs: number | null
/** Majority vote across samples that were actually probed. */
remounted: boolean | null
/** Set only when every sample for this surface errored. */
error: string | null
}
export interface MeasureOptions {
/** Number of first-visit/revisit round-trips to sample (default 3). */
runs?: number
navTimeoutMs?: number
contentTimeoutMs?: number
}
function median(values: Array<number | null>): number | null {
const nums = values.filter((v): v is number => v != null).sort((a, b) => a - b)
if (nums.length === 0) return null
const mid = Math.floor(nums.length / 2)
return nums.length % 2 === 0 ? (nums[mid - 1]! + nums[mid]!) / 2 : nums[mid]!
}
/** Sweep-line over [start, start+duration] intervals — max overlap count and
* total wall-clock span from first start to last end. */
function deriveConcurrency(calls: RpcCall[]): { maxConcurrentRpc: number | null; rpcWallClockMs: number | null } {
const timed = calls.filter((c) => c.durationMs != null)
if (timed.length === 0) return { maxConcurrentRpc: calls.length > 0 ? null : 0, rpcWallClockMs: calls.length > 0 ? null : 0 }
type Edge = { at: number; delta: number }
const edges: Edge[] = []
let minStart = Infinity
let maxEnd = -Infinity
for (const c of timed) {
const end = c.startedAtMs + (c.durationMs ?? 0)
edges.push({ at: c.startedAtMs, delta: 1 }, { at: end, delta: -1 })
minStart = Math.min(minStart, c.startedAtMs)
maxEnd = Math.max(maxEnd, end)
}
edges.sort((a, b) => a.at - b.at)
let running = 0
let max = 0
for (const e of edges) {
running += e.delta
if (running > max) max = running
}
return { maxConcurrentRpc: max, rpcWallClockMs: maxEnd - minStart }
}
interface RpcTracker {
calls: RpcCall[]
detach: () => void
}
function attachRpcTracker(page: Page, phaseStart: number): RpcTracker {
const calls: RpcCall[] = []
const pending = new Map<Request, { method: string; start: number }>()
const isRpcRequest = (req: Request) => req.method() === 'POST' && req.url().includes(RPC_PATH)
const onRequest = (req: Request) => {
if (!isRpcRequest(req)) return
let method = 'unknown'
try {
const body = req.postData()
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
} catch {
// Malformed/unreadable body — keep 'unknown', never persist the body itself.
}
pending.set(req, { method, start: Date.now() - phaseStart })
}
const onSettled = (req: Request) => {
const info = pending.get(req)
if (!info) return
pending.delete(req)
calls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - phaseStart - info.start })
}
page.on('request', onRequest)
page.on('requestfinished', onSettled)
page.on('requestfailed', onSettled)
return {
calls,
detach: () => {
page.off('request', onRequest)
page.off('requestfinished', onSettled)
page.off('requestfailed', onSettled)
},
}
}
/**
* Click with a dismiss-and-retry guard: a stray overlay (e.g. the Companion
* app's once-per-browser auto-show intro) can appear mid-attempt, after
* `dismissOverlays()` already ran but before Playwright's own actionability
* wait resolves. Splitting the timeout budget across a few short attempts,
* re-running `dismissOverlays()` between each, clears it reliably instead of
* burning the whole budget on a single blocked attempt.
*/
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
const attempts = 3
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
await dismissOverlays(page)
try {
await page.locator(selector).first().click({ timeout: perAttemptMs })
return
} catch (err) {
lastErr = err
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
for (const selector of steps) {
await clickWithGuard(page, selector, timeoutMs)
}
}
async function stampRoot(page: Page, rootSelector: string, mark: string): Promise<boolean> {
return page.evaluate(
({ sel, mark }) => {
const el = document.querySelector(sel) as HTMLElement | null
if (!el) return false
el.dataset.perfProbe = mark
return true
},
{ sel: rootSelector, mark }
)
}
async function readRootProbe(page: Page, rootSelector: string): Promise<string | null> {
return page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null
return el?.dataset.perfProbe ?? null
}, rootSelector)
}
/**
* Best-effort dismissal of a stray full-screen overlay (a filter/pairing/
* update modal opened by the surface under test, or left over from a prior
* one) before it blocks the next click. One surface's leftover UI must never
* cascade a click-intercept failure into every remaining surface in the run.
*/
async function dismissOverlays(page: Page): Promise<void> {
try {
await page.keyboard.press('Escape')
} catch {
// no-op — best-effort only
}
// Match any "Close"-flavoured aria-label (e.g. "Close companion modal"),
// not just an exact "Close" — modal close buttons across the app phrase
// this inconsistently.
const closeButtons = page.locator(
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
)
const count = await closeButtons.count().catch(() => 0)
if (count > 0) {
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
}
}
async function goHome(page: Page, timeoutMs: number): Promise<void> {
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — if a prior surface's
// run ended sitting on /dashboard/chat (e.g. chat's own revisit re-opened
// it as its last step, or a prior run errored out mid-chat), the sidebar is
// hidden and unclickable. Back out of chat first so the sidebar reappears
// before relying on it below.
if (new URL(page.url()).pathname === '/dashboard/chat') {
const closed = await page
.locator('.chat-close-btn')
.first()
.click({ timeout: 5_000 })
.then(() => true)
.catch(() => false)
// On real AIUI hardware the close pill can sit behind AIUI's own
// "connecting" overlay for longer than any reasonable click budget — if
// the close button itself is unreachable, this is a recovery path, not a
// measurement, so a hard reload to break out is preferable to leaving
// every remaining surface stuck behind a permanently hidden sidebar.
if (!closed) {
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
}
}
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
}
async function runOnce(page: Page, surface: Surface, opts: Required<Pick<MeasureOptions, 'navTimeoutMs' | 'contentTimeoutMs'>>): Promise<SurfaceSample> {
const { navTimeoutMs, contentTimeoutMs } = opts
// Start every run from a known, stable point (dashboard home) so
// firstVisitMs measures "navigate from the dashboard home to the surface"
// exactly as specified, regardless of what the previous surface left us on.
await goHome(page, navTimeoutMs)
const t0 = Date.now()
const firstTracker = attachRpcTracker(page, t0)
await clickChain(page, surface.navSteps, navTimeoutMs)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
const firstVisitMs = Date.now() - t0
firstTracker.detach()
const firstVisitRpcCount = firstTracker.calls.length
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
const stamped = await stampRoot(page, surface.rootSelector, mark)
// Away step: for an in-page trigger (modal), close it; otherwise navigate
// to the neutral Settings tab via the sidebar (never back to this surface's
// own navSteps, so the round-trip is genuine).
//
// Deliberately NOT routed through clickWithGuard here: dismissOverlays()
// treats any "Close"-labelled button inside a dialog as a stray overlay to
// dismiss — which is exactly this element when closeSelector targets the
// surface's own dialog/panel, causing it to close a beat before this click
// runs and leaving the click with nothing to find.
if (surface.closeSelector) {
await page.locator(surface.closeSelector).first().click({ timeout: navTimeoutMs })
} else {
await clickWithGuard(page, NEUTRAL_SELECTOR, navTimeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: navTimeoutMs })
}
const t1 = Date.now()
const revisitTracker = attachRpcTracker(page, t1)
await clickChain(page, surface.navSteps, navTimeoutMs)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
const revisitMs = Date.now() - t1
revisitTracker.detach()
const revisitRpcCalls = revisitTracker.calls
const revisitRpcCount = revisitRpcCalls.length
const remounted = stamped ? (await readRootProbe(page, surface.rootSelector)) !== mark : null
const { maxConcurrentRpc, rpcWallClockMs } = deriveConcurrency(revisitRpcCalls)
return {
firstVisitMs,
revisitMs,
firstVisitRpcCount,
revisitRpcCount,
revisitRpcCalls,
maxConcurrentRpc,
rpcWallClockMs,
remounted,
error: null,
}
}
function errorSample(err: unknown): SurfaceSample {
return {
firstVisitMs: null,
revisitMs: null,
firstVisitRpcCount: null,
revisitRpcCount: null,
revisitRpcCalls: [],
maxConcurrentRpc: null,
rpcWallClockMs: null,
remounted: null,
error: err instanceof Error ? err.message : String(err),
}
}
export async function measureSurface(page: Page, surface: Surface, opts: MeasureOptions = {}): Promise<SurfaceMeasurement> {
const runs = opts.runs ?? 3
const navTimeoutMs = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT
const contentTimeoutMs = opts.contentTimeoutMs ?? DEFAULT_CONTENT_TIMEOUT
const samples: SurfaceSample[] = []
for (let i = 0; i < runs; i++) {
try {
samples.push(await runOnce(page, surface, { navTimeoutMs, contentTimeoutMs }))
} catch (err) {
samples.push(errorSample(err))
}
}
const successful = samples.filter((s) => s.error == null)
const firstVisitMs = median(successful.map((s) => s.firstVisitMs))
const revisitMs = median(successful.map((s) => s.revisitMs))
const firstVisitRpcCount = median(successful.map((s) => s.firstVisitRpcCount))
const revisitRpcCount = median(successful.map((s) => s.revisitRpcCount))
// Representative sample for the call trace / concurrency fields: the
// successful sample whose revisitMs is closest to the computed median
// (falls back to the last successful sample when no median exists).
let representative: SurfaceSample | null = null
if (successful.length > 0) {
if (revisitMs != null) {
representative = successful.reduce((best, s) =>
Math.abs((s.revisitMs ?? Infinity) - revisitMs) < Math.abs((best.revisitMs ?? Infinity) - revisitMs) ? s : best
)
} else {
representative = successful[successful.length - 1]!
}
}
const remountedVotes = successful.map((s) => s.remounted).filter((v): v is boolean => v != null)
const trueCount = remountedVotes.filter(Boolean).length
const remounted = remountedVotes.length === 0 ? null : trueCount >= remountedVotes.length - trueCount
return {
id: surface.id,
label: surface.label,
path: surface.path,
kind: surface.kind,
runs,
samples,
firstVisitMs,
revisitMs,
firstVisitRpcCount,
revisitRpcCount,
revisitRpcCalls: representative?.revisitRpcCalls ?? [],
maxConcurrentRpc: representative?.maxConcurrentRpc ?? null,
rpcWallClockMs: representative?.rpcWallClockMs ?? null,
remounted,
error: successful.length === 0 ? (samples[samples.length - 1]?.error ?? 'all runs failed') : null,
}
}
+420
View File
@@ -0,0 +1,420 @@
// profile-revisit.spec.ts — 02-11 gap-closure diagnostic (D-10: measure before
// fix). Standalone, additive script — does NOT edit surfaces.ts / measure.ts
// / surface-perf.spec.ts (the frozen 02-01 harness stays frozen).
//
// 02-VERIFICATION.md's gap 2 confirmed six surfaces regressed on revisit with
// flat-or-improved RPC counts — i.e. the cost is client-side render/reactivity,
// not network. This script captures a REAL CPU profile (CDP Profiler domain —
// the same sampling data Chrome DevTools' Performance panel visualizes as a
// flame chart) during exactly the harness's own measured window (click chain
// -> contentSelector visible) for each named surface's revisit, then
// aggregates self-time by function so the dominant cost can be named with
// profiling evidence instead of guessed from source reading alone.
//
// Usage:
// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
import { expect, test, type Page } from '@playwright/test'
import { SURFACES, type Surface } from './surfaces'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
const NAV_TIMEOUT = 20_000
const CONTENT_TIMEOUT = 20_000
// Every surface 02-VERIFICATION.md named as regressed, plus Fleet (the
// out-of-scope bonus 02-10 flagged as the most severe magnitude of the same
// mechanism) — all six of this gap plan's must-haves.
const TARGET_IDS = ['web5', 'server', 'discover', 'app-details', 'openwrt-gateway', 'fleet']
async function login(page: Page): Promise<void> {
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
.first()
.click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
async function dismissOverlays(page: Page): Promise<void> {
try {
await page.keyboard.press('Escape')
} catch {
/* best-effort */
}
const closeButtons = page.locator(
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
)
const count = await closeButtons.count().catch(() => 0)
if (count > 0) await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
// HealthNotifications.vue's dismiss button has no aria-label at all (a bare
// SVG X icon) — the aria-label selector above never matches it, and per
// 02-FINDINGS.md's Outstanding section its `.fixed.right-4.z-[200]` wrapper
// has no `pointer-events: none`, so a still-open toast can intercept a
// click meant for page content underneath it (e.g. a disk-usage warning
// sitting over the "OpenWrt Gateway" link). Dismiss any visible one.
const healthToastClose = page.locator('.fixed.right-4.z-\\[200\\] button')
const healthCount = await healthToastClose.count().catch(() => 0)
for (let i = 0; i < healthCount; i++) {
await healthToastClose.first().click({ timeout: 1_000 }).catch(() => {})
}
}
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
const attempts = 4
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
let lastErr: unknown
for (let i = 0; i < attempts; i++) {
await dismissOverlays(page)
try {
await page.locator(selector).first().click({ timeout: perAttemptMs, force: i === attempts - 1 })
return
} catch (err) {
lastErr = err
}
}
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
}
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
for (const selector of steps) await clickWithGuard(page, selector, timeoutMs)
}
async function goHome(page: Page, timeoutMs: number): Promise<void> {
if (new URL(page.url()).pathname === '/dashboard/chat') {
const closed = await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).then(() => true).catch(() => false)
if (!closed) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
}
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
}
interface CpuProfileNode {
id: number
callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number; scriptId: string }
hitCount?: number
children?: number[]
}
interface CpuProfile {
nodes: CpuProfileNode[]
startTime: number
endTime: number
samples?: number[]
timeDeltas?: number[]
}
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
// 404 on a test node), so minified function names inside vendor-*.js/
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
// together; index = app entry/shared code; per-route chunk name = that
// view's own lazy-loaded code) rather than by node_modules path, which only
// exists pre-build.
function categorize(url: string): string {
if (!url) return '(native/gc/idle — no JS frame)'
const file = url.split('/').pop() ?? url
if (/^vendor-/.test(file)) return 'assets/vendor-*.js (Vue/Pinia/vue-router runtime bundle)'
if (/^index-/.test(file)) return 'assets/index-*.js (app entry/shared chunk)'
if (/^Dashboard-/.test(file)) return 'assets/Dashboard-*.js (Dashboard/DashboardRouterView chunk)'
if (/^Fleet-/.test(file)) return 'assets/Fleet-*.js (Fleet.vue chunk)'
if (/^Web5-/i.test(file)) return 'assets/Web5-*.js chunk'
if (/^Server-/.test(file)) return 'assets/Server-*.js chunk'
if (/^Discover-/.test(file)) return 'assets/Discover-*.js chunk'
if (/^AppDetails-/.test(file)) return 'assets/AppDetails-*.js chunk'
if (/^OpenWrtGateway-/.test(file)) return 'assets/OpenWrtGateway-*.js chunk'
if (url.startsWith('assets/')) return `assets/${file} (other chunk)`
if (url.includes('node_modules')) return 'other node_modules (dev-only build)'
return '(native/gc/idle — no JS frame)'
}
function analyzeProfile(profile: CpuProfile, label: string): void {
const byId = new Map<number, CpuProfileNode>()
for (const n of profile.nodes) byId.set(n.id, n)
const selfTimeById = new Map<number, number>()
const samples = profile.samples ?? []
const timeDeltas = profile.timeDeltas ?? []
let total = 0
for (let i = 0; i < samples.length; i++) {
const dt = timeDeltas[i] ?? 0
total += dt
const id = samples[i]!
selfTimeById.set(id, (selfTimeById.get(id) ?? 0) + dt)
}
const totalMs = total / 1000
const windowMs = (profile.endTime - profile.startTime) / 1000
// Aggregate by function identity (name@file:line) and by coarse category.
const byFunction = new Map<string, number>()
const byCategory = new Map<string, number>()
for (const [id, us] of selfTimeById) {
const node = byId.get(id)
if (!node) continue
const fnName = node.callFrame.functionName || '(anonymous)'
const file = node.callFrame.url ? node.callFrame.url.split('/').slice(-2).join('/') : '(native)'
const key = `${fnName} @ ${file}:${node.callFrame.lineNumber}`
byFunction.set(key, (byFunction.get(key) ?? 0) + us)
const cat = categorize(node.callFrame.url)
byCategory.set(cat, (byCategory.get(cat) ?? 0) + us)
}
console.log(`\n===== CPU PROFILE: ${label} =====`)
console.log(`Profiled window (Profiler start->stop): ${windowMs.toFixed(1)}ms`)
console.log(`Total sampled self-time: ${totalMs.toFixed(1)}ms (${samples.length} samples)`)
console.log(`--- By category (self time) ---`)
const catSorted = Array.from(byCategory.entries()).sort((a, b) => b[1] - a[1])
for (const [cat, us] of catSorted) {
const ms = us / 1000
console.log(` ${ms.toFixed(1)}ms (${((us / total) * 100).toFixed(1)}%) ${cat}`)
}
console.log(`--- Top 20 functions (self time) ---`)
const fnSorted = Array.from(byFunction.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
for (const [key, us] of fnSorted) {
const ms = us / 1000
console.log(` ${ms.toFixed(2)}ms (${((us / total) * 100).toFixed(1)}%) ${key}`)
}
}
test.describe.configure({ mode: 'serial' })
test('profile revisit CPU cost for named regressed surfaces', async ({ page }) => {
test.setTimeout(15 * 60 * 1000)
await login(page)
for (const id of TARGET_IDS) {
const surface = SURFACES.find((s) => s.id === id) as Surface
expect(surface, `surface ${id} must exist in SURFACES`).toBeTruthy()
await goHome(page, NAV_TIMEOUT)
// First visit — warm the cache (matches the harness's own first-visit/
// revisit structure) — not profiled.
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
// Away — to the neutral Settings tab (or close, for an in-page trigger).
if (surface.closeSelector) {
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
} else {
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
}
// Let the away-transition settle before starting the profiler so it
// captures only the revisit window, not Settings' own leave-transition.
await page.waitForTimeout(600)
// Instrument window.setTimeout/requestAnimationFrame for the profiled
// window only, so a large "idle" share in the CPU profile can be
// attributed to a concrete scheduled delay (app timer) vs. a chain of
// animation frames (CSS-transition-bound) vs. neither (network wait).
await page.evaluate(() => {
const w = window as unknown as {
__timerLog: Array<{ type: string; delay?: number; at: number }>
__origSetTimeout: typeof setTimeout
__origRaf: typeof requestAnimationFrame
}
w.__timerLog = []
w.__origSetTimeout = window.setTimeout.bind(window)
w.__origRaf = window.requestAnimationFrame.bind(window)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setTimeout = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
w.__timerLog.push({ type: 'setTimeout', delay, at: performance.now() })
return w.__origSetTimeout(fn as any, delay, ...args) // eslint-disable-line @typescript-eslint/no-explicit-any
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
w.__timerLog.push({ type: 'raf', at: performance.now() })
return w.__origRaf(cb)
}
})
// Wall-clock RPC start/finish (matches measure.ts's own attachRpcTracker
// convention: Date.now() offsets, method name only, no bodies — T-02-06).
const rpcCalls: Array<{ method: string; startedAtMs: number; durationMs: number | null }> = []
const pendingRpc = new Map<import('@playwright/test').Request, { method: string; start: number }>()
let tRpc0 = 0
const isRpc = (req: import('@playwright/test').Request) => req.method() === 'POST' && req.url().includes('/rpc/v1')
const onReq = (req: import('@playwright/test').Request) => {
if (!isRpc(req)) return
let method = 'unknown'
try {
const body = req.postData()
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
} catch {
/* keep 'unknown' */
}
pendingRpc.set(req, { method, start: Date.now() - tRpc0 })
}
const onSettled = (req: import('@playwright/test').Request) => {
const info = pendingRpc.get(req)
if (!info) return
pendingRpc.delete(req)
rpcCalls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - tRpc0 - info.start })
}
page.on('request', onReq)
page.on('requestfinished', onSettled)
page.on('requestfailed', onSettled)
// Capture every CSS transition/animation start+end on the document during
// the window — decisive evidence for/against "a long CSS transition is
// what the human waits through" (Playwright's own 'visible' check does
// NOT wait for transitions/opacity, only a non-empty bounding box + not
// visibility:hidden, so a slow transition would NOT show up as CPU cost
// or as a blocked contentSelector — it would show up here, and would
// explain a "feels slow" gap between first-paint and contentSelector).
await page.evaluate(() => {
const w = window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }
w.__animLog = []
const describe = (el: EventTarget | null): string => {
const e = el as HTMLElement | null
if (!e || !e.tagName) return '(unknown)'
const cls = (e.className && typeof e.className === 'string') ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
return `${e.tagName.toLowerCase()}${cls}`
}
const log = (type: string) => (ev: Event) => {
const te = ev as TransitionEvent | AnimationEvent
w.__animLog.push({
type,
name: (te as TransitionEvent).propertyName ?? (te as AnimationEvent).animationName ?? '',
target: describe(ev.target),
elapsedMs: Math.round((te.elapsedTime ?? 0) * 1000),
at: performance.now(),
})
}
document.addEventListener('transitionrun', log('transitionrun'), true)
document.addEventListener('transitionend', log('transitionend'), true)
document.addEventListener('transitioncancel', log('transitioncancel'), true)
document.addEventListener('animationstart', log('animationstart'), true)
document.addEventListener('animationend', log('animationend'), true)
})
const client = await page.context().newCDPSession(page)
await client.send('Profiler.enable')
await client.send('Profiler.setSamplingInterval', { interval: 100 })
await client.send('Profiler.start')
// Raw Chrome trace events (the SAME data DevTools' Performance panel
// renders as a flame chart / summary tab) — categorizes rendering work
// (Layout, RecalculateStyles, Paint, CompositeLayers, RunTask, TimerFire,
// FireAnimationFrame, ...) that a bare JS CPU profile only sees as
// "(program)"/"(idle)" because layout/paint/compositing run on the same
// renderer main thread but outside any JS call frame.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const traceEvents: any[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client.on('Tracing.dataCollected', (data: any) => { traceEvents.push(...(data.value ?? [])) })
const tracingComplete = new Promise<void>((resolve) => client.once('Tracing.tracingComplete', () => resolve()))
await client.send('Tracing.start', {
categories: 'disabled-by-default-devtools.timeline,devtools.timeline,toplevel,v8,blink.user_timing',
transferMode: 'ReportEvents',
})
// First-paint probe, independent of Playwright's own contentSelector
// wait: records performance.now() the moment contentSelector FIRST gets
// a non-empty bounding box, polled every animation frame (not gated by
// Playwright's stricter "visible" actionability rules) — so first-paint
// and content-visible can be reported as two distinct numbers per the
// gap plan's explicit ask.
const pageT0 = await page.evaluate((sel) => {
const w = window as unknown as { __firstPaintAt: number | null }
w.__firstPaintAt = null
function poll() {
if (w.__firstPaintAt == null) {
const el = document.querySelector(sel)
if (el) {
const r = el.getBoundingClientRect()
if (r.width > 0 && r.height > 0) {
w.__firstPaintAt = performance.now()
return
}
}
requestAnimationFrame(poll)
}
}
requestAnimationFrame(poll)
return performance.now()
}, surface.contentSelector)
const t0 = Date.now()
tRpc0 = t0
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
const wallMs = Date.now() - t0
const firstPaintAt = await page.evaluate(() => (window as unknown as { __firstPaintAt: number | null }).__firstPaintAt)
const firstPaintMs = firstPaintAt != null ? Math.round(firstPaintAt - pageT0) : null
const { profile } = await client.send('Profiler.stop')
await client.send('Profiler.disable')
await client.send('Tracing.end')
await tracingComplete
await client.detach().catch(() => {})
page.off('request', onReq)
page.off('requestfinished', onSettled)
page.off('requestfailed', onSettled)
// Find the renderer main-thread tid (thread_name metadata event) so the
// breakdown below isn't polluted by compositor/IO/GPU-process threads.
const mainThreadMeta = traceEvents.find(
(e) => e.ph === 'M' && e.name === 'thread_name' && (e.args?.name === 'CrRendererMain')
)
const mainTid = mainThreadMeta?.tid
const durByName = new Map<string, number>()
for (const e of traceEvents) {
if (mainTid != null && e.tid !== mainTid) continue
if (e.ph !== 'X') continue // complete events only (have a real duration)
if (typeof e.dur !== 'number') continue
durByName.set(e.name, (durByName.get(e.name) ?? 0) + e.dur)
}
console.log(`--- Chrome trace event self/total time by name (main thread, category=devtools.timeline; NOTE: 'X' events can nest, so this is TOTAL not self time — use for relative magnitude, not a sum-to-100% budget) ---`)
const traceSorted = Array.from(durByName.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
for (const [name, us] of traceSorted) {
console.log(` ${(us / 1000).toFixed(1)}ms ${name}`)
}
const timerLog = await page.evaluate(() => {
const w = window as unknown as {
__timerLog: Array<{ type: string; delay?: number; at: number }>
__origSetTimeout: typeof setTimeout
__origRaf: typeof requestAnimationFrame
}
const log = w.__timerLog ?? []
window.setTimeout = w.__origSetTimeout
window.requestAnimationFrame = w.__origRaf
return log
})
const animLog = await page.evaluate(() => (window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }).__animLog ?? [])
console.log(`\n>>> ${surface.label} (${surface.id}) revisit wall-clock: ${wallMs}ms | first-paint: ${firstPaintMs}ms (contentSelector's first non-empty bounding box, unrelated to Playwright's own stricter 'visible' check)`)
analyzeProfile(profile as CpuProfile, `${surface.label} (${surface.id})`)
const setTimeoutEntries = timerLog.filter((e) => e.type === 'setTimeout')
const rafCount = timerLog.filter((e) => e.type === 'raf').length
console.log(`--- Timers scheduled during the revisit window (app-code setTimeout/rAF calls) ---`)
console.log(` setTimeout calls: ${setTimeoutEntries.length}${setTimeoutEntries.length ? ' — delays(ms): ' + setTimeoutEntries.map((e) => e.delay ?? 0).sort((a, b) => a - b).join(',') : ''}`)
console.log(` requestAnimationFrame calls: ${rafCount}`)
console.log(`--- CSS transition/animation events during the window (relative to window start, pageT0) ---`)
if (animLog.length === 0) {
console.log(' (none observed)')
} else {
for (const e of animLog) {
console.log(` +${Math.round(e.at - pageT0)}ms ${e.type} name="${e.name}" target=${e.target} elapsed=${e.elapsedMs}ms`)
}
}
console.log(`--- RPC calls during the revisit window (wall-clock ms from window start) ---`)
for (const c of rpcCalls) {
console.log(` ${c.method}: start=+${c.startedAtMs}ms duration=${c.durationMs}ms`)
}
console.log(` total RPC calls: ${rpcCalls.length}, wall-clock window: ${wallMs}ms`)
}
})
+105
View File
@@ -0,0 +1,105 @@
// Re-runnable surface-perf harness (02-01-PLAN.md Task 1). Logs in using the
// exact flow from app-launch.spec.ts, walks every SURFACES row via
// measureSurface(), and writes the full result array + a run header to
// ARCHY_PERF_OUT (defaulting to e2e/test-results/surface-perf.json).
//
// Redaction is structural, not a cleanup pass: measure.ts's RpcTracker only
// ever records method name + timing (T-02-06) — request/response bodies,
// page text and screenshots are never captured into the artifact.
import { execSync } from 'node:child_process'
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { measureSurface, type SurfaceMeasurement } from './measure'
import { SURFACES } from './surfaces'
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
const RUNS = process.env.ARCHY_PERF_RUNS ? Number(process.env.ARCHY_PERF_RUNS) : 3
const OUT_PATH = resolve(process.cwd(), process.env.ARCHY_PERF_OUT ?? 'e2e/test-results/surface-perf.json')
async function login(page: Page): Promise<void> {
// Mirrors e2e/app-launch.spec.ts's login() verbatim — do not invent a
// second auth path.
await page.goto('/login', { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
await page.goto('/login', { waitUntil: 'networkidle' })
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
await page
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
.first()
.click()
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
}
function currentCommit(): string {
try {
// `__dirname` is unavailable under this package's `"type": "module"` ESM
// runtime (Playwright's own transform swallows the ReferenceError into
// the catch below, silently yielding 'unknown') — use `process.cwd()`
// instead, which Playwright always sets to the project root it was
// invoked from.
return execSync('git rev-parse --short HEAD', { cwd: process.cwd() }).toString().trim()
} catch {
return 'unknown'
}
}
test('surface-perf: measure every D-09 surface and write the baseline artifact', async ({ page, baseURL }) => {
test.setTimeout(20 * 60 * 1000) // 15 surfaces x 3 runs x network round-trips can run long on a real node
await login(page)
const results: SurfaceMeasurement[] = []
const skipped: string[] = []
for (const surface of SURFACES) {
try {
const measurement = await measureSurface(page, surface, { runs: RUNS })
results.push(measurement)
if (measurement.error) skipped.push(`${surface.id}: ${measurement.error}`)
} catch (err) {
// A surface that throws outside measureSurface's own per-run try/catch
// (e.g. login state got corrupted) is still recorded, never dropped —
// an unmeasured surface must never silently disappear from the array.
results.push({
id: surface.id,
label: surface.label,
path: surface.path,
kind: surface.kind,
runs: RUNS,
samples: [],
firstVisitMs: null,
revisitMs: null,
firstVisitRpcCount: null,
revisitRpcCount: null,
revisitRpcCalls: [],
maxConcurrentRpc: null,
rpcWallClockMs: null,
remounted: null,
error: err instanceof Error ? err.message : String(err),
})
skipped.push(`${surface.id}: ${err instanceof Error ? err.message : String(err)}`)
}
}
const header = {
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
takenAt: new Date().toISOString(),
commit: currentCommit(),
runs: RUNS,
notes: skipped.length > 0 ? `Skipped/errored surfaces: ${skipped.join('; ')}` : 'All surfaces measured cleanly.',
}
const artifact = { ...header, results }
mkdirSync(dirname(OUT_PATH), { recursive: true })
writeFileSync(OUT_PATH, JSON.stringify(artifact, null, 2))
expect(results.length).toBe(SURFACES.length)
})
+213
View File
@@ -0,0 +1,213 @@
// SURFACES table for the Phase 02 UI-performance profiling harness (02-01-PLAN.md,
// Task 1). One row per D-09 surface. Every `path` here must exist in
// `neode-ui/src/router/index.ts` (verified by acceptance criteria).
//
// `navSteps` are ordered, real UI-click selectors — clicking an <a>/<RouterLink>
// or a button that calls `router.push()` triggers genuine client-side SPA
// navigation (no full page reload), which is what "remount storm" measurement
// requires. `page.goto()` is deliberately NOT used to move between dashboard
// surfaces — it would force a full reload every time and always show
// remounted=true regardless of whether KeepAlive would have helped.
//
// `contentSelector` is chosen to be present only once real content has
// painted (never a loading skeleton/spinner). `rootSelector` is the stable
// outermost element of the mounted view, used by the remount probe in
// measure.ts. `.view-container` is a class Dashboard.vue's nested
// `<router-view>` merges onto every non-chat/non-mesh view's root element via
// Vue's automatic fallthrough-attribute merging (verified: `grep -rn
// "view-container" src` shows it added only by Dashboard.vue's default
// branch) — so it works as a uniform root selector across most surfaces.
// Mesh and Chat take Dashboard.vue's other template branch (no class
// fallthrough) so they use their own static root class instead.
export type SurfaceKind = 'main-tab' | 'secondary'
export interface Surface {
id: string
label: string
/** Route path, must exist in router/index.ts. */
path: string
kind: SurfaceKind
/** A selector present only once real content has painted (not a skeleton/spinner). */
contentSelector: string
/** The stable outermost element of the view, used for the remount probe. */
rootSelector: string
/**
* Ordered selectors clicked (via real UI interaction) to reach/open this
* surface. Every step is resolved with `.first()` and clicked; the chain
* is re-run unchanged for the revisit measurement.
*/
navSteps: string[]
/**
* When set, this surface is an in-page trigger (e.g. a modal) rather than
* a distinct navigable route (D-09's "Wallet / send flows" no Wallet.vue
* exists; the real surface is the Send button on the Home dashboard wallet
* card, opening SendBitcoinModal). Revisit is measured by clicking this to
* close, then re-running the last `navSteps` entry to reopen "open to
* content" rather than "navigate to content" per the plan's fallback rule.
*/
closeSelector?: string
}
const SIDEBAR = '[data-controller-zone="sidebar"]'
export const SURFACES: Surface[] = [
{
id: 'home',
label: 'Home (wallet figures)',
path: '/dashboard',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.home-card',
navSteps: [`${SIDEBAR} a[href="/dashboard"]`],
},
{
id: 'apps',
label: 'Apps (My Apps)',
path: '/dashboard/apps',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.apps-card-grid-desktop [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`],
},
{
id: 'marketplace',
label: 'Marketplace (App Store)',
path: '/dashboard/marketplace',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.marketplace-container [data-controller-container]',
// Marketplace has no sidebar entry — reached via Home's "Browse Store" link.
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'a:has-text("Browse Store")'],
},
{
id: 'discover',
label: 'Discover (App Store tab)',
path: '/dashboard/discover',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.discover-container [data-controller-container]',
// Discover has no sidebar entry — reached via the "App Store" tab inside Apps.
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-view a:has-text("App Store")'],
},
{
id: 'cloud',
label: 'Cloud / Files',
path: '/dashboard/cloud',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.apps-view [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`],
},
{
id: 'mesh',
label: 'Mesh',
path: '/dashboard/mesh',
kind: 'main-tab',
rootSelector: '.mesh-view',
contentSelector: '.mesh-status-grid',
navSteps: [`${SIDEBAR} a[href="/dashboard/mesh"]`],
},
{
id: 'server',
label: 'Server (Network)',
path: '/dashboard/server',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`],
},
{
id: 'web5',
label: 'Web5',
path: '/dashboard/web5',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`],
},
{
id: 'fleet',
label: 'Fleet',
path: '/dashboard/fleet',
kind: 'main-tab',
rootSelector: '.view-container',
contentSelector: '.view-container [data-controller-container]',
// Fleet's sidebar entry is hidden for beta — reached via Web5's Federation
// card link. Web5Federation.vue renders TWO "Fleet" links: a
// `.web5-card-actions-top` one that CSS permanently hides
// (`display: none` — the "compact header variants are permanently
// retired" rule in style.css) and the real, visible one inside
// `.web5-card-actions-bottom-grid`. Scope to the latter so `.first()`
// doesn't land on the hidden copy.
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`, '.web5-card-actions-bottom-grid a:has-text("Fleet")'],
},
{
id: 'chat',
label: 'Chat (AIUI)',
path: '/dashboard/chat',
kind: 'main-tab',
rootSelector: '.chat-fullscreen',
contentSelector: '.chat-iframe, .chat-placeholder',
navSteps: [`${SIDEBAR} button:has-text("AIUI")`],
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — the sidebar (and
// therefore the neutral Settings link) is not visible while on Chat, so
// the normal "away via sidebar" step is impossible here. Chat's own close
// button calls closeChat() (router.back(), landing on wherever we came
// from — Home, per navSteps below), which is the surface's real "away".
closeSelector: '.chat-close-btn',
},
{
id: 'app-details',
label: 'AppDetails (secondary)',
path: '/dashboard/apps/:id',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.app-details-container h1',
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-card-grid-desktop [data-controller-container]'],
},
{
id: 'marketplace-app-details',
label: 'MarketplaceAppDetails (secondary)',
path: '/dashboard/marketplace/:id',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.app-details-container h1',
navSteps: [
`${SIDEBAR} a[href="/dashboard"]`,
'a:has-text("Browse Store")',
'.marketplace-container [data-controller-container]',
],
},
{
id: 'cloud-folder',
label: 'CloudFolder (secondary)',
path: '/dashboard/cloud/:folderId',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: '.cloud-folder-container h1',
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`, '.apps-view [data-controller-container]'],
},
{
id: 'openwrt-gateway',
label: 'OpenWrtGateway (secondary)',
path: '/dashboard/server/openwrt',
kind: 'secondary',
rootSelector: '.view-container',
contentSelector: 'h1:has-text("OpenWrt Gateway")',
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`, 'a:has-text("OpenWrt Gateway")'],
},
{
id: 'wallet-send',
label: 'Wallet / send flow (Home wallet card, SendBitcoinModal)',
// Not a route — RESEARCH.md found no Wallet.vue; the real surface is a
// modal opened from the Home wallet card. Path recorded for reference
// only (the page the trigger lives on).
path: '/dashboard',
kind: 'secondary',
rootSelector: '[role="dialog"]',
contentSelector: 'h3:has-text("Send Bitcoin")',
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'button:has-text("Send")'],
closeSelector: '[role="dialog"] button[aria-label="Close"]',
},
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+134
View File
@@ -0,0 +1,134 @@
import { test, type Page } from '@playwright/test'
const SCREENSHOT_DIR = './e2e/screenshots'
const PASSWORD = 'password123'
/** Set localStorage values to skip splash screen and onboarding */
async function skipSplashAndOnboarding(page: Page) {
await page.goto('/login')
await page.evaluate(() => {
localStorage.setItem('neode_intro_seen', '1')
localStorage.setItem('neode_onboarding_complete', '1')
})
}
async function login(page: Page) {
await skipSplashAndOnboarding(page)
await page.goto('/login')
await page.waitForLoadState('networkidle')
// Wait for the password input to appear (server health check may delay it)
const passwordInput = page.locator('input[type="password"]').first()
await passwordInput.waitFor({ timeout: 15_000 })
await passwordInput.fill(PASSWORD)
// Click the login/submit button
const submitBtn = page
.locator(
'button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]',
)
.first()
await submitBtn.click()
// Wait for navigation to dashboard
await page.waitForURL('**/dashboard**', { timeout: 15_000 })
await page.waitForLoadState('networkidle')
// Wait for home page content to confirm dashboard is loaded
await page.locator('text=Welcome Noderunner').waitFor({ timeout: 10_000 })
await page.waitForTimeout(1500)
}
/** Navigate to a dashboard child route via sidebar link click */
async function navigateTo(page: Page, path: string, waitForText: string) {
// Use in-page navigation to avoid full SPA reload
await page.evaluate((p) => {
window.history.pushState({}, '', p)
window.dispatchEvent(new PopStateEvent('popstate'))
}, path)
// Wait for the page-specific content to appear
await page.locator(`text=${waitForText}`).first().waitFor({ timeout: 10_000 })
// Let content settle after route change
await page.waitForTimeout(800)
}
async function screenshot(page: Page, name: string) {
// Wait for any animations to settle
await page.waitForTimeout(1000)
await page.screenshot({
path: `${SCREENSHOT_DIR}/${name}.png`,
fullPage: true,
})
}
test.describe('Visual Regression — Public Pages', () => {
test('login page', async ({ page }) => {
await skipSplashAndOnboarding(page)
await page.goto('/login')
await page.waitForLoadState('networkidle')
// Wait for server health check and form to become active
await page.locator('input[type="password"]').first().waitFor({ timeout: 15_000 })
await page.waitForTimeout(1000)
await screenshot(page, '01-login')
})
})
test.describe('Visual Regression — Dashboard Pages', () => {
test.beforeEach(async ({ page }) => {
await login(page)
})
test('home / dashboard', async ({ page }) => {
// Already on home after login
await screenshot(page, '02-dashboard-home')
})
test('apps list', async ({ page }) => {
await navigateTo(page, '/dashboard/apps', 'My Apps')
await screenshot(page, '03-apps-list')
})
test('marketplace', async ({ page }) => {
await navigateTo(page, '/dashboard/marketplace', 'App Store')
await screenshot(page, '04-marketplace')
})
test('cloud storage', async ({ page }) => {
await navigateTo(page, '/dashboard/cloud', 'Cloud')
await screenshot(page, '05-cloud')
})
test('server', async ({ page }) => {
await navigateTo(page, '/dashboard/server', 'Network')
await screenshot(page, '06-server')
})
test('web5', async ({ page }) => {
await navigateTo(page, '/dashboard/web5', 'Web5')
await screenshot(page, '07-web5')
})
test('settings', async ({ page }) => {
await navigateTo(page, '/dashboard/settings', 'Settings')
await screenshot(page, '08-settings')
})
test('chat', async ({ page }) => {
await navigateTo(page, '/dashboard/chat', 'AI Assistant')
await screenshot(page, '09-chat')
})
test('federation', async ({ page }) => {
await navigateTo(page, '/dashboard/server/federation', 'Federation')
await screenshot(page, '10-federation')
})
test('credentials', async ({ page }) => {
await navigateTo(page, '/dashboard/web5/credentials', 'Credentials')
await screenshot(page, '11-credentials')
})
test('system update', async ({ page }) => {
await navigateTo(page, '/dashboard/settings/update', 'System Update')
await screenshot(page, '12-system-update')
})
})
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Quick fix for k484 nginx SPA routing
# Run this after installing k484 if /admin route doesn't work
echo "🔧 Fixing k484 nginx configuration for SPA routing..."
if ! /usr/local/bin/docker ps --filter name=k484-test --format "{{.Names}}" | grep -q k484-test; then
echo "❌ k484-test container is not running"
echo " Install k484 first through the Neode UI"
exit 1
fi
# Update nginx config for SPA routing
/usr/local/bin/docker exec k484-test sh -c 'cat > /etc/nginx/conf.d/default.conf << "EOF"
server {
listen 80;
listen [::]:80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
EOF
'
# Fix logo permissions
/usr/local/bin/docker exec k484-test chmod 644 /usr/share/nginx/html/k484-logo.png 2>/dev/null || true
# Restart nginx
/usr/local/bin/docker restart k484-test > /dev/null
echo "✅ k484 nginx config fixed!"
echo " Try http://localhost:8103/admin now"
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/icon/favico-black-v2.svg" />
<link rel="icon" href="/favicon-v2.ico" sizes="48x48" />
<link rel="icon" type="image/png" sizes="64x64" href="/assets/icon/pwa-64x64-v2.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon/pwa-192x192-v2.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/assets/icon/pwa-512x512-v2.png" />
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon-180x180-v2.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icon/apple-touch-icon-180x180-v2.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/assets/icon/pwa-192x192-v2.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/assets/icon/pwa-512x512-v2.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Archipelago - Your sovereign personal server" />
<meta name="theme-color" content="#000000" />
<!-- Dark-only app: pin native controls dark before CSS loads. -->
<meta name="color-scheme" content="dark" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Archipelago" />
<meta name="application-name" content="Archipelago" />
<meta name="msapplication-TileColor" content="#000000" />
<meta name="msapplication-TileImage" content="/assets/icon/pwa-192x192-v2.png" />
<title>Archipelago OS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+6419
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
#!/bin/bash
# Video Optimization Script for Web - 1MB Target
# Optimizes video-intro.mp4 to ~1MB for fast web loading
set -e
VIDEO_DIR="public/assets/video"
INPUT_FILE="${VIDEO_DIR}/video-intro.mp4"
OUTPUT_FILE="${VIDEO_DIR}/video-intro-optimized.mp4"
BACKUP_FILE="${VIDEO_DIR}/video-intro-backup-$(date +%Y%m%d-%H%M%S).mp4"
echo "🎬 Video Optimization Script - 1MB Target"
echo "=========================================="
echo ""
# Check if FFmpeg is installed
if ! command -v ffmpeg &> /dev/null; then
echo "❌ FFmpeg is not installed."
echo ""
echo "Install it with:"
echo " macOS: brew install ffmpeg"
echo " Linux: sudo apt install ffmpeg"
echo " Windows: Download from https://ffmpeg.org/download.html"
exit 1
fi
# Check if input file exists
if [ ! -f "$INPUT_FILE" ]; then
echo "❌ Input file not found: $INPUT_FILE"
exit 1
fi
echo "📹 Input file: $INPUT_FILE"
INPUT_SIZE=$(du -h "$INPUT_FILE" | cut -f1)
echo " Size: $INPUT_SIZE"
echo ""
# Get video info
echo "📊 Analyzing video..."
DURATION=$(ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT_FILE" 2>/dev/null || echo "unknown")
RESOLUTION=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$INPUT_FILE" 2>/dev/null || echo "unknown")
FPS=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 "$INPUT_FILE" 2>/dev/null | awk -F'/' '{print $1/$2}' | head -1 || echo "unknown")
echo " Duration: ${DURATION}s"
echo " Resolution: ${RESOLUTION}"
echo " Frame rate: ${FPS}fps"
echo ""
# Create backup
echo "💾 Creating backup..."
cp "$INPUT_FILE" "$BACKUP_FILE"
echo " Backup saved to: $BACKUP_FILE"
echo ""
# Optimize video for 1MB target
echo "⚙️ Optimizing video for ~1MB target..."
echo " Resolution: 1280x720 (HD)"
echo " Frame rate: 30fps"
echo " CRF: 30 (good quality, smaller file)"
echo " Audio: 64kbps (background music quality)"
echo ""
ffmpeg -i "$INPUT_FILE" \
-c:v libx264 \
-preset slow \
-crf 30 \
-profile:v high \
-level 4.0 \
-pix_fmt yuv420p \
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2" \
-r 30 \
-c:a aac \
-b:a 64k \
-ar 44100 \
-movflags +faststart \
-threads 0 \
-y \
"$OUTPUT_FILE" 2>&1 | grep -E "(Duration|Stream|frame|size|time)" || true
if [ $? -eq 0 ] && [ -f "$OUTPUT_FILE" ]; then
echo ""
echo "✅ Optimization complete!"
echo ""
OUTPUT_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1)
OUTPUT_BYTES=$(stat -f%z "$OUTPUT_FILE" 2>/dev/null || stat -c%s "$OUTPUT_FILE" 2>/dev/null)
if [ -n "$OUTPUT_BYTES" ]; then
OUTPUT_MB=$(echo "scale=2; $OUTPUT_BYTES / 1024 / 1024" | bc 2>/dev/null || echo "unknown")
echo "📊 Results:"
echo " Original size: $INPUT_SIZE"
echo " Optimized size: $OUTPUT_SIZE (~${OUTPUT_MB}MB)"
# Calculate compression ratio
ORIGINAL_BYTES=$(stat -f%z "$INPUT_FILE" 2>/dev/null || stat -c%s "$INPUT_FILE" 2>/dev/null)
if [ -n "$ORIGINAL_BYTES" ] && [ -n "$OUTPUT_BYTES" ]; then
RATIO=$(echo "scale=1; ($ORIGINAL_BYTES - $OUTPUT_BYTES) * 100 / $ORIGINAL_BYTES" | bc)
echo " Size reduction: ${RATIO}%"
# Check if target achieved
TARGET_BYTES=1048576 # 1MB in bytes
if [ "$OUTPUT_BYTES" -gt "$TARGET_BYTES" ]; then
EXCESS_MB=$(echo "scale=2; ($OUTPUT_BYTES - $TARGET_BYTES) / 1024 / 1024" | bc)
echo ""
echo "⚠️ File size is ${EXCESS_MB}MB over 1MB target"
echo " Current: ~${OUTPUT_MB}MB"
echo ""
echo " Options to reduce further:"
echo " - Use CRF 32 (slightly lower quality, smaller file)"
echo " - Reduce resolution to 854x480"
echo " - Reduce frame rate to 24fps"
else
echo ""
echo "✅ Target achieved! File is under 1MB"
fi
fi
fi
echo ""
echo "🔄 Replacing original file..."
mv "$OUTPUT_FILE" "$INPUT_FILE"
echo " ✅ Original file replaced with optimized version"
echo ""
echo "💡 To restore backup:"
echo " mv \"$BACKUP_FILE\" \"$INPUT_FILE\""
else
echo ""
echo "❌ Optimization failed. Original file preserved."
rm -f "$OUTPUT_FILE"
exit 1
fi
echo ""
echo "✨ Done! Video optimized for web (~1MB target)."
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
# Video Optimization Script for Web
# Optimizes video-intro.mp4 for web use while preserving quality
set -e
VIDEO_DIR="public/assets/video"
INPUT_FILE="${VIDEO_DIR}/video-intro.mp4"
OUTPUT_FILE="${VIDEO_DIR}/video-intro-optimized.mp4"
BACKUP_FILE="${VIDEO_DIR}/video-intro-backup-$(date +%Y%m%d-%H%M%S).mp4"
echo "🎬 Video Optimization Script"
echo "============================"
echo ""
# Check if FFmpeg is installed
if ! command -v ffmpeg &> /dev/null; then
echo "❌ FFmpeg is not installed."
echo ""
echo "Install it with:"
echo " macOS: brew install ffmpeg"
echo " Linux: sudo apt install ffmpeg"
echo " Windows: Download from https://ffmpeg.org/download.html"
exit 1
fi
# Check if input file exists
if [ ! -f "$INPUT_FILE" ]; then
echo "❌ Input file not found: $INPUT_FILE"
exit 1
fi
echo "📹 Input file: $INPUT_FILE"
INPUT_SIZE=$(du -h "$INPUT_FILE" | cut -f1)
echo " Size: $INPUT_SIZE"
echo ""
# Get video info
echo "📊 Analyzing video..."
DURATION=$(ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT_FILE" 2>/dev/null || echo "unknown")
RESOLUTION=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$INPUT_FILE" 2>/dev/null || echo "unknown")
echo " Duration: ${DURATION}s"
echo " Resolution: ${RESOLUTION}"
echo ""
# Create backup
echo "💾 Creating backup..."
cp "$INPUT_FILE" "$BACKUP_FILE"
echo " Backup saved to: $BACKUP_FILE"
echo ""
# Optimize video for web (target ~1MB)
echo "⚙️ Optimizing video for web (target ~1MB)..."
echo " Using H.264 with optimized settings"
echo " Preset: slow (best compression efficiency)"
echo " Resolution: 1280x720 (HD, good quality)"
echo " Frame rate: 30fps (smooth playback)"
echo ""
ffmpeg -i "$INPUT_FILE" \
-c:v libx264 \
-preset slow \
-crf 28 \
-profile:v high \
-level 4.0 \
-pix_fmt yuv420p \
-vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2" \
-r 30 \
-c:a aac \
-b:a 64k \
-ar 44100 \
-movflags +faststart \
-threads 0 \
-y \
"$OUTPUT_FILE" 2>&1 | grep -E "(Duration|Stream|frame|size|time)" || true
if [ $? -eq 0 ]; then
echo ""
echo "✅ Optimization complete!"
echo ""
OUTPUT_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1)
OUTPUT_BYTES=$(stat -f%z "$OUTPUT_FILE" 2>/dev/null || stat -c%s "$OUTPUT_FILE" 2>/dev/null)
OUTPUT_MB=$(echo "scale=2; $OUTPUT_BYTES / 1024 / 1024" | bc 2>/dev/null || echo "unknown")
echo "📊 Results:"
echo " Original size: $INPUT_SIZE"
echo " Optimized size: $OUTPUT_SIZE (~${OUTPUT_MB}MB)"
# Calculate compression ratio
ORIGINAL_BYTES=$(stat -f%z "$INPUT_FILE" 2>/dev/null || stat -c%s "$INPUT_FILE" 2>/dev/null)
if [ -n "$ORIGINAL_BYTES" ] && [ -n "$OUTPUT_BYTES" ]; then
RATIO=$(echo "scale=1; ($ORIGINAL_BYTES - $OUTPUT_BYTES) * 100 / $ORIGINAL_BYTES" | bc)
echo " Size reduction: ${RATIO}%"
# Check if target achieved
TARGET_BYTES=1048576 # 1MB in bytes
if [ "$OUTPUT_BYTES" -gt "$TARGET_BYTES" ]; then
EXCESS=$(echo "scale=1; ($OUTPUT_BYTES - $TARGET_BYTES) / 1024 / 1024" | bc)
echo ""
echo "⚠️ File size is ${EXCESS}MB over 1MB target"
echo " Consider using CRF 30-32 for smaller file size"
else
echo ""
echo "✅ Target achieved! File is under 1MB"
fi
fi
echo ""
echo "🔄 Replacing original file..."
mv "$OUTPUT_FILE" "$INPUT_FILE"
echo " ✅ Original file replaced with optimized version"
echo ""
echo "💡 To restore backup:"
echo " mv \"$BACKUP_FILE\" \"$INPUT_FILE\""
else
echo ""
echo "❌ Optimization failed. Original file preserved."
rm -f "$OUTPUT_FILE"
exit 1
fi
echo ""
echo "✨ Done! Video optimized for web (~1MB target)."
+12613
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.127-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
"stop": "./stop-dev.sh",
"test": "vitest run",
"test:watch": "vitest",
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
"dev": "vite",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
"backend:real": "cd ../core && cargo run --release",
"prebuild": "cp ../app-catalog/catalog.json public/catalog.json",
"build": "vue-tsc -b && vite build",
"build:docker": "vite build",
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"generate-pwa-icons": "pwa-assets-generator --preset minimal-2023 public/assets/icon/favico-black.svg && cp public/assets/icon/favicon.ico public/favicon.ico",
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
},
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"qrloop": "^1.4.1",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/d3": "^7.4.3",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.0",
"@types/qrcode": "^1.5.6",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^3.2.4",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"autoprefixer": "^10.4.22",
"concurrently": "^9.1.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dockerode": "^4.0.9",
"express": "^4.21.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.18",
"typescript": "~5.9.3",
"vite": "^7.2.2",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^3.1.1",
"vue-tsc": "^3.1.3",
"ws": "^8.18.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/test-results',
timeout: 60_000,
expect: {
timeout: 10_000,
},
use: {
baseURL: process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'off',
ignoreHTTPSErrors: true,
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
})
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+899
View File
@@ -0,0 +1,899 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Archipelago — LoRa & Mesh Functionality Guide</title>
<style>
:root {
--bg: #000000;
--glass-card: rgba(0, 0, 0, 0.65);
--glass-dark: rgba(0, 0, 0, 0.35);
--glass-darker: rgba(0, 0, 0, 0.6);
--glass-border: rgba(255, 255, 255, 0.18);
--glass-highlight: rgba(255, 255, 255, 0.22);
--glass-blur: 18px;
--glass-blur-strong: 24px;
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
--text: rgba(255, 255, 255, 0.9);
--text-muted: rgba(255, 255, 255, 0.6);
--accent: #fb923c;
--accent-dim: rgba(251, 146, 60, 0.15);
--green: #4ade80;
--green-dim: rgba(74, 222, 128, 0.15);
--red: #ef4444;
--red-dim: rgba(239, 68, 68, 0.12);
--blue: #3b82f6;
--blue-dim: rgba(59, 130, 246, 0.12);
--yellow: #facc15;
--yellow-dim: rgba(250, 204, 21, 0.12);
--purple: #a78bfa;
--purple-dim: rgba(167, 139, 250, 0.12);
--radius: 16px;
--radius-sm: 12px;
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.7;
font-size: 16px;
}
nav {
position: fixed;
top: 0; left: 0;
width: 280px;
height: 100vh;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border-right: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass);
overflow-y: auto;
padding: 24px 0;
z-index: 100;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.15) transparent;
}
nav .logo { padding: 0 24px 20px; margin-bottom: 16px; }
nav .logo h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 18px; font-weight: 700;
color: var(--accent); letter-spacing: -0.02em;
}
nav .logo p { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
nav .nav-section {
padding: 12px 16px 4px;
font-size: 10px; font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
}
nav a {
display: block;
padding: 6px 24px;
color: var(--text-muted);
text-decoration: none;
font-size: 13px;
transition: all var(--transition);
border-left: 2px solid transparent;
}
nav a:hover, nav a.active {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
border-left-color: var(--accent);
}
main {
margin-left: 280px;
max-width: 960px;
padding: 48px 48px 120px;
}
h2 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 28px; font-weight: 700;
margin: 64px 0 8px;
padding-top: 24px;
color: var(--text);
letter-spacing: -0.02em;
}
h2:first-of-type { margin-top: 0; }
h3 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 20px; font-weight: 600;
margin: 40px 0 12px;
color: var(--text);
}
h4 {
font-size: 16px; font-weight: 600;
margin: 24px 0 8px;
color: var(--accent);
}
p { margin: 8px 0 16px; color: var(--text); }
ul, ol { margin: 8px 0 16px 24px; color: var(--text); }
li { margin: 4px 0; }
.subtitle {
font-size: 15px;
color: var(--text-muted);
margin-bottom: 32px;
}
.hero { text-align: center; padding: 48px 0 56px; margin-bottom: 24px; }
.hero h1 {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 42px; font-weight: 800;
background: linear-gradient(135deg, var(--accent), #f59e0b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.03em;
}
.hero .tagline {
font-size: 18px;
color: var(--text-muted);
margin: 12px auto 0;
max-width: 640px;
}
.hero .meta {
margin-top: 20px;
display: flex; gap: 16px;
justify-content: center; flex-wrap: wrap;
}
.hero .meta span {
font-size: 12px;
padding: 4px 12px;
border-radius: 999px;
background: var(--glass-dark);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
color: var(--text-muted);
}
.card {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 16px 0;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
margin: 16px 0;
}
.card-sm {
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px 20px;
transition: transform var(--transition), box-shadow var(--transition);
}
.card-sm:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
.card-sm p { font-size: 13px; color: var(--text-muted); margin: 0; }
.badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 2px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-green { background: var(--green-dim); color: var(--green); }
.badge-red { background: var(--red-dim); color: var(--red); }
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
.badge-blue { background: var(--blue-dim); color: var(--blue); }
.badge-purple { background: var(--purple-dim); color: var(--purple); }
.badge-accent { background: var(--accent-dim); color: var(--accent); }
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 16px 0;
font-size: 13px;
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-glass);
}
th {
text-align: left;
padding: 10px 14px;
background: rgba(0, 0, 0, 0.4);
color: var(--text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--glass-border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255, 255, 255, 0.04); }
code {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 13px;
background: rgba(0, 0, 0, 0.4);
padding: 2px 6px;
border-radius: 4px;
color: var(--accent);
}
pre {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 20px;
overflow-x: auto;
margin: 16px 0;
font-size: 13px;
line-height: 1.6;
box-shadow: var(--shadow-glass);
}
pre code { background: none; padding: 0; color: var(--text); }
.diagram {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur-strong));
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 24px;
margin: 20px 0;
overflow-x: auto;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
white-space: pre;
}
.diagram .highlight { color: var(--accent); font-weight: 600; }
.diagram .green { color: var(--green); }
.diagram .blue { color: var(--blue); }
.diagram .red { color: var(--red); }
.diagram .purple { color: var(--purple); }
.callout {
background: var(--glass-card);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: 16px 20px;
margin: 16px 0;
font-size: 14px;
border-left: 3px solid;
box-shadow: var(--shadow-glass);
}
.callout-info { border-color: var(--blue); }
.callout-warn { border-color: var(--yellow); }
.callout-danger { border-color: var(--red); }
.callout-success { border-color: var(--green); }
.callout-learn {
border-color: var(--purple);
background: rgba(167, 139, 250, 0.06);
position: relative;
padding-top: 32px;
}
.callout-learn::before {
content: 'Layman Analogy';
position: absolute;
top: 10px; left: 20px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--purple);
}
.callout strong { display: block; margin-bottom: 4px; }
.score-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin: 20px 0;
}
.score-card {
background: var(--glass-darker);
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
border-radius: var(--radius);
padding: 16px;
text-align: center;
transition: transform var(--transition);
}
.score-card:hover { transform: translateY(-2px); }
.score-card .score {
font-family: 'Montserrat', 'Avenir Next', sans-serif;
font-size: 28px; font-weight: 800;
margin: 4px 0;
color: var(--accent);
}
.score-card .label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
hr {
border: none;
border-top: 1px solid var(--glass-border);
margin: 48px 0;
}
@media (max-width: 900px) {
nav { display: none; }
main { margin-left: 0; padding: 20px; }
.hero h1 { font-size: 32px; }
}
</style>
</head>
<body>
<nav>
<div class="logo">
<h1>Archipelago</h1>
<p>LoRa &amp; Mesh Guide</p>
</div>
<div class="nav-section">Overview</div>
<a href="#intro">Introduction</a>
<a href="#layman">What is LoRa?</a>
<a href="#why">Why Archipelago uses it</a>
<div class="nav-section">Stack</div>
<a href="#hardware">Hardware &amp; Firmware</a>
<a href="#serial">USB Serial Transport</a>
<a href="#wire">Wire Format</a>
<a href="#crypto">Encryption Layers</a>
<a href="#fragmentation">Fragmentation</a>
<div class="nav-section">Routing</div>
<a href="#dual-transport">Dual Transport</a>
<a href="#addressing">Addressing</a>
<a href="#synthetic">Federation Contacts</a>
<div class="nav-section">Messages</div>
<a href="#msg-overview">All 23 Types</a>
<a href="#msg-text">Text / Reply / Edit</a>
<a href="#msg-social">Reactions &amp; Receipts</a>
<a href="#msg-content">Content / Files</a>
<a href="#msg-bitcoin">Bitcoin &amp; Lightning</a>
<a href="#msg-safety">Alerts &amp; Presence</a>
<a href="#msg-identity">Identity &amp; Keys</a>
<div class="nav-section">Operations</div>
<a href="#rpc">RPC API</a>
<a href="#ui">User Interface</a>
<a href="#listener">Listener Loop</a>
<a href="#files">File Map</a>
</nav>
<main>
<section class="hero">
<h1>LoRa &amp; Mesh Functionality</h1>
<p class="tagline">How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.</p>
<div class="meta">
<span>Meshcore Companion USB</span>
<span>Double Ratchet E2E</span>
<span>23 Message Types</span>
<span>160-byte LoRa Frame</span>
</div>
</section>
<h2 id="intro">Introduction</h2>
<p>This document explains Archipelago's mesh subsystem — the code under <code>core/archipelago/src/mesh/</code> that lets nodes talk to each other over <strong>LoRa radio</strong> instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.</p>
<p>The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.</p>
<h2 id="layman">What is LoRa? <span class="badge badge-purple">Layman</span></h2>
<div class="callout callout-learn">
<strong>Think of LoRa as a whisper that travels 10 kilometers.</strong>
Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about <strong>160 bytes</strong> at a time, and each whisper takes a second or two to complete.
</div>
<p>Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses <em>chirp spread spectrum</em> (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 515 km with a simple antenna; data rates are 0.350 kbps.</p>
<p>Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running <strong>Meshcore firmware</strong>, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.</p>
<h2 id="why">Why Archipelago uses it</h2>
<div class="card-grid">
<div class="card-sm">
<h4>Off-grid safety</h4>
<p>Dead-man switch and emergency alerts reach family without cell coverage.</p>
</div>
<div class="card-sm">
<h4>Censorship resistance</h4>
<p>No ISP, no DNS, no TLS termination — just radio waves between nodes.</p>
</div>
<div class="card-sm">
<h4>Bitcoin when internet is down</h4>
<p>Relay signed transactions and Lightning payments through on-grid peers.</p>
</div>
<div class="card-sm">
<h4>Truly peer-to-peer chat</h4>
<p>Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.</p>
</div>
</div>
<hr>
<h2 id="hardware">Hardware &amp; Firmware</h2>
<p>Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.</p>
<table>
<thead><tr><th>Component</th><th>Role</th><th>Examples</th></tr></thead>
<tbody>
<tr><td><strong>MCU</strong></td><td>Runs Meshcore firmware, talks USB serial</td><td>ESP32, nRF52840</td></tr>
<tr><td><strong>Radio</strong></td><td>Semtech LoRa transceiver</td><td>SX1262, SX1276</td></tr>
<tr><td><strong>Board</strong></td><td>MCU + radio + USB + antenna</td><td>Heltec V3, T-Beam, RAK WisBlock, Station G2</td></tr>
<tr><td><strong>Firmware</strong></td><td>Mesh routing + Companion USB protocol</td><td>Meshcore</td></tr>
<tr><td><strong>Connection</strong></td><td>USB CDC-ACM serial</td><td><code>/dev/mesh-radio</code> (udev symlink), <code>/dev/ttyUSB*</code>, <code>/dev/ttyACM*</code></td></tr>
<tr><td><strong>Link params</strong></td><td>115200 baud, 8N1</td><td>Set in <code>mesh/serial.rs</code></td></tr>
</tbody>
</table>
<div class="callout callout-learn">
<strong>It's a modem.</strong> Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through.
</div>
<h2 id="serial">USB Serial Transport</h2>
<p>Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with <code>'&lt;'</code> and listens for <code>'&gt;'</code>.</p>
<div class="diagram">Host → Device: <span class="highlight">0x3C</span> '&lt;' │ <span class="blue">len_lo len_hi</span><span class="green">frame_bytes...</span>
Device → Host: <span class="highlight">0x3E</span> '&gt;' │ <span class="blue">len_lo len_hi</span><span class="green">frame_bytes...</span>
Baud: 115200 Framing: 8N1 Source: mesh/serial.rs</div>
<p>The frame body is a Meshcore <em>Companion</em> command or response. Archipelago builds these in <code>mesh/protocol.rs</code> and parses replies in <code>mesh/listener/decode.rs</code>.</p>
<h3>Companion commands Archipelago uses</h3>
<table>
<thead><tr><th>Code</th><th>Name</th><th>Purpose</th></tr></thead>
<tbody>
<tr><td><code>0x01</code></td><td>APP_START</td><td>Handshake; device returns its node_id and name</td></tr>
<tr><td><code>0x02</code></td><td>SEND_TXT_MSG</td><td>Send payload to a contact (targeted by 6-byte pubkey prefix)</td></tr>
<tr><td><code>0x03</code></td><td>SEND_CHANNEL_TXT_MSG</td><td>Broadcast on a channel (no specific recipient)</td></tr>
<tr><td><code>0x04</code></td><td>GET_CONTACTS</td><td>Pull the device's contact table</td></tr>
<tr><td><code>0x06</code></td><td>SET_DEVICE_TIME</td><td>Sync Unix timestamp for message dating</td></tr>
<tr><td><code>0x07</code></td><td>SEND_SELF_ADVERT</td><td>Broadcast our identity onto the mesh</td></tr>
<tr><td><code>0x08</code></td><td>SET_ADVERT_NAME</td><td>Set our display name</td></tr>
<tr><td><code>0x0A</code></td><td>SYNC_NEXT_MESSAGE</td><td>Pop the next queued inbound message</td></tr>
<tr><td><code>0x0B</code></td><td>SET_RADIO_PARAMS</td><td>Frequency, spreading factor, bandwidth</td></tr>
<tr><td><code>0x0C</code></td><td>SET_RADIO_TX_POWER</td><td>Transmit power (dBm)</td></tr>
<tr><td><code>0x38</code></td><td>GET_STATS</td><td>Device statistics</td></tr>
</tbody>
</table>
<h3>Responses and push notifications</h3>
<p>Responses begin with a status byte. Codes <code>&lt; 0x80</code> are replies to a command we sent; codes <code>&gt;= 0x80</code> are asynchronous push events from the device.</p>
<table>
<thead><tr><th>Code</th><th>Name</th><th>Meaning</th></tr></thead>
<tbody>
<tr><td><code>0x00</code></td><td>RESP_OK</td><td>Command accepted</td></tr>
<tr><td><code>0x01</code></td><td>RESP_ERR</td><td>Command failed + error code</td></tr>
<tr><td><code>0x03</code></td><td>RESP_CONTACT</td><td>One contact entry (32-byte pubkey + metadata)</td></tr>
<tr><td><code>0x05</code></td><td>RESP_SELF_INFO</td><td>Our node_id and name after APP_START</td></tr>
<tr><td><code>0x10</code></td><td>RESP_CONTACT_MSG_V3</td><td>Direct inbound message (SNR + sender prefix + payload)</td></tr>
<tr><td><code>0x11</code></td><td>RESP_CHANNEL_MSG_V3</td><td>Channel broadcast inbound</td></tr>
<tr><td><code>0x83</code></td><td>PUSH_MESSAGES_WAITING</td><td>Async: new messages in queue, call SYNC_NEXT_MESSAGE</td></tr>
</tbody>
</table>
<h2 id="wire">Wire Format — the payload byte 0</h2>
<p>Once a frame reaches the message payload, Archipelago looks at the <strong>first byte</strong> to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.</p>
<div class="diagram"><span class="highlight">0x00</span> Plain text (legacy, unencrypted)
<span class="highlight">0x01</span> Identity broadcast (ARCHY:2 / ARCHY:3)
<span class="highlight">0x02</span> Typed CBOR envelope (plaintext, used for debug or intra-LAN)
<span class="highlight">0xEE</span> Encrypted typed — ChaCha20-Poly1305 w/ static shared secret
<span class="highlight">0xDD</span> Ratcheted typed — Double Ratchet, forward-secure</div>
<p>Markers <code>0xEE</code> and <code>0xDD</code> are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.</p>
<h3>0xEE — static-key encrypted envelope</h3>
<pre><code>[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]</code></pre>
<ul>
<li>Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.</li>
<li>Cipher: ChaCha20-Poly1305 AEAD.</li>
<li>Max plaintext: <code>160 1 12 16 = 131</code> bytes (see <code>crypto::MAX_ENCRYPTED_PLAINTEXT</code>).</li>
<li>Properties: confidential + authenticated, <em>but</em> compromise of a key decrypts all history.</li>
</ul>
<h3>0xDD — Double Ratchet envelope</h3>
<pre><code>[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]</code></pre>
<ul>
<li>Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).</li>
<li>Handles out-of-order delivery via a skipped-keys cache.</li>
<li>Properties: forward secrecy + post-compromise recovery. Used for <code>mesh.*</code> chat once a session is established.</li>
<li>Implementation: <code>mesh/ratchet.rs</code>, session load/save in <code>mesh/listener/session.rs</code>.</li>
</ul>
<div class="callout callout-learn">
<strong>Static key vs. ratchet = a safe vs. a self-shredding envelope.</strong>
The <code>0xEE</code> lane is like a locked safe: one key opens everything. The <code>0xDD</code> lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages.
</div>
<h2 id="crypto">Encryption Layers</h2>
<p>Three cryptographic primitives combine to produce the <code>0xDD</code> ratchet flow:</p>
<div class="card-grid">
<div class="card-sm">
<h4>X25519 ECDH</h4>
<p>Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.</p>
</div>
<div class="card-sm">
<h4>HKDF-SHA256</h4>
<p>Derives root key, chain key, and message key at each ratchet step.</p>
</div>
<div class="card-sm">
<h4>ChaCha20-Poly1305</h4>
<p>Symmetric AEAD used for the actual payload encryption + authentication tag.</p>
</div>
</div>
<h3>Session bootstrap — X3DH-like handshake</h3>
<p>Before the ratchet can start, peers exchange a <strong>PrekeyBundle</strong> (type 5) and a <strong>SessionInit</strong> (type 6). Those two messages are carried by the <code>0xEE</code> static-key envelope, because the ratchet session doesn't exist yet. Once <code>SessionInit</code> is processed, subsequent traffic switches to <code>0xDD</code>. See <code>mesh/x3dh.rs</code>.</p>
<h2 id="fragmentation">Fragmentation — how a 500-byte message rides a 160-byte pipe</h2>
<p>The LoRa frame budget is <strong>160 bytes</strong> (<code>protocol::MAX_MESSAGE_LEN</code>). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.</p>
<div class="diagram"><span class="highlight">Chunk header</span> ┌──────────┬──────────┬────────────┐
│ type (1) │ id (1) │ total (1) │
└──────────┴──────────┴────────────┘
<span class="highlight">Chunk body</span> Up to 140 bytes of Base64-encoded payload
Sender: compress → encrypt → split into 140-char chunks
→ send with tiny inter-chunk delay
Receiver: accumulate by (sender, chunk_id) → reassemble
→ decrypt → decompress → dispatch</div>
<p>For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.</p>
<div class="callout callout-info">
<strong>Escape hatch: federation fallback.</strong> If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago <em>skips LoRa entirely</em> and routes the message over Tor federation instead. See the <code>ContentRef</code> path in <code>rpc/mesh/typed_messages.rs</code>.
</div>
<h2 id="dual-transport">Dual Transport — LoRa + Tor federation</h2>
<p>Archipelago treats LoRa and Tor federation as <strong>two lanes of the same highway</strong>. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.</p>
<div class="diagram"> ┌──────────────────┐
│ mesh.send(...) │
└────────┬─────────┘
┌──────────┴──────────┐
│ Is peer synthetic? │
└──────────┬──────────┘
No │ Yes
┌──────────┘ └──────────┐
▼ ▼
<span class="highlight">LoRa radio</span> <span class="blue">Tor federation</span>
(160-byte frame) (unlimited, slower setup)
│ │
│ if &gt; 160 B &amp;&amp; synth ──────┘ (fallback)
Chunked over LoRa
or refused if no fallback</div>
<h2 id="addressing">Addressing</h2>
<ul>
<li><strong>Contact ID</strong> — 32-bit handle from Meshcore's contact table. Used by <code>SEND_TXT_MSG</code>.</li>
<li><strong>Pubkey prefix</strong> — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.</li>
<li><strong>DID / onion</strong> — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.</li>
</ul>
<h2 id="synthetic">Synthetic federation contacts</h2>
<p>To let the chat list show federation peers <em>before</em> any message arrives, Archipelago inserts <strong>synthetic contacts</strong> into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (<code>≥ 0x8000_0000</code>), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.</p>
<hr>
<h2 id="msg-overview">All 23 Message Types</h2>
<p>Every typed message is a CBOR envelope identified by a single <code>MeshMessageType</code> byte. The <strong>Transport</strong> column shows which marker carries it on the wire and which Companion command is used.</p>
<table>
<thead><tr>
<th>ID</th><th>Type</th><th>Purpose</th><th>Marker</th><th>Cmd</th><th>Chunked?</th>
</tr></thead>
<tbody>
<tr><td>0</td><td>Text</td><td>Plain chat message</td><td>0xDD</td><td>0x02</td><td>If &gt;160 B</td></tr>
<tr><td>1</td><td>Alert</td><td>Emergency / dead-man heartbeat</td><td>0xDD</td><td>0x02/0x03</td><td>No (short)</td></tr>
<tr><td>2</td><td>Invoice</td><td>Lightning / BOLT11 invoice</td><td>0xDD</td><td>0x02</td><td>Usually</td></tr>
<tr><td>3</td><td>PsbtHash</td><td>Unsigned tx hash for co-signing</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>4</td><td>Coordinate</td><td>GPS location share</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>5</td><td>PrekeyBundle</td><td>X3DH bootstrap (pre-session)</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
<tr><td>6</td><td>SessionInit</td><td>Initial ratchet message</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
<tr><td>7</td><td>BlockHeader</td><td>Bitcoin block height/hash</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
<tr><td>8</td><td>TxRelay</td><td>Signed Bitcoin tx for on-grid peer to broadcast</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
<tr><td>9</td><td>TxRelayResponse</td><td>txid or error from the relay peer</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>10</td><td>LightningRelay</td><td>BOLT11 to pay via on-grid peer</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
<tr><td>11</td><td>LightningRelayResponse</td><td>payment_hash or error</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>12</td><td>TxConfirmation</td><td>Depth update (1/2/3 confs)</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>13</td><td>Reply</td><td>Quoted reply to a previous message</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
<tr><td>14</td><td>Reaction</td><td>Emoji reaction on MessageKey</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>15</td><td>ReadReceipt</td><td>"Seen up to MessageKey X"</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>16</td><td>Forward</td><td>Re-forwarded original w/ provenance</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
<tr><td>17</td><td>Edit</td><td>In-place text replacement</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
<tr><td>18</td><td>Delete</td><td>Tombstone for earlier message</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
<tr><td>19</td><td>ContentRef</td><td>CID of blob held by sender (file/image)</td><td>0xDD</td><td>0x02 or Tor</td><td>Federation fallback</td></tr>
<tr><td>20</td><td>Presence</td><td>Heartbeat + last-activity epoch</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
<tr><td>21</td><td>ChannelInvite</td><td>Group membership announcement</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
<tr><td>22</td><td>ContactCard</td><td>Shareable federation node card</td><td>0xDD</td><td>0x02</td><td>Maybe</td></tr>
</tbody>
</table>
<p>The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.</p>
<h2 id="msg-text">Text, Reply, Edit, Delete, Forward</h2>
<h3>Text (type 0)</h3>
<p><strong>Sender path.</strong> <code>rpc.mesh.send</code><code>typed_messages::send_text</code> → CBOR-encode the <code>Text{body}</code> variant → ratchet-encrypt → prefix <code>0xDD</code> → if under 160 B, send in one <code>SEND_TXT_MSG</code> frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.</p>
<h3>Reply (type 13)</h3>
<p>Same as Text, but the CBOR envelope carries a <code>MessageKey</code> pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.</p>
<h3>Edit (type 17)</h3>
<p>Envelope contains the original <code>MessageKey</code> plus the new body. Receiver updates its local store in-place and tags the entry "edited".</p>
<h3>Delete (type 18)</h3>
<p>Tombstone only: <code>MessageKey</code> with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.</p>
<h3>Forward (type 16)</h3>
<p>Wraps original <code>{sender_name, original_timestamp, body}</code> so the receiver can render "Forwarded from &lt;name&gt;". Because the body is nested, forwards are <em>almost always</em> chunked.</p>
<h2 id="msg-social">Reaction, ReadReceipt, Presence</h2>
<h3>Reaction (type 14)</h3>
<p>Envelope: <code>{target: MessageKey, emoji: String}</code>. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see <code>MessageActions</code> in <code>neode-ui</code>).</p>
<h3>ReadReceipt (type 15)</h3>
<p>Envelope: <code>{up_to: MessageKey}</code>. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).</p>
<h3>Presence (type 20)</h3>
<p>Periodic heartbeat carrying <code>{last_activity_epoch}</code>. Broadcast on a channel (<code>SEND_CHANNEL_TXT_MSG</code>, cmd <code>0x03</code>) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.</p>
<div class="callout callout-learn">
<strong>Like a lighthouse beacon.</strong> Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed.
</div>
<h2 id="msg-content">ContentRef — files and images without bloating the radio</h2>
<p>LoRa cannot move a 500 KB image. The <code>ContentRef</code> type (19) solves this by sending only a <strong>pointer</strong> — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.</p>
<div class="diagram">Sender Receiver
────── ────────
store blob locally (CID)
┌──────────────────────┐
│ ContentRef {cid, │ ──ratchet──▶
│ mime, size, │ 0xDD
│ thumb_hash} │ over LoRa
└──────────────────────┘
see CID in chat
click to fetch
┌─────────────────┐
│ rpc.mesh.fetch- │
│ content(cid) │
└────────┬────────┘
federation (Tor)
resolve DID → pull blob</div>
<div class="callout callout-info">
<strong>Resolution bug fix note.</strong> An earlier revision of <code>ContentRef</code> routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit <code>5f7ebf14</code>) resolves the owning peer by DID and falls back to name-match only if DID lookup fails.
</div>
<h2 id="msg-bitcoin">Bitcoin &amp; Lightning over LoRa</h2>
<p>Archipelago uses the mesh as a <strong>Bitcoin transport of last resort</strong>. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.</p>
<h3>TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)</h3>
<div class="diagram">Offline signer On-grid relay peer Bitcoin p2p
────────────── ────────────────── ───────────
sign tx
┌─────────────┐
│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate
│ {raw_tx} │ broadcast via bitcoind ───▶ mempool
└─────────────┘ │
┌────────────────────────┐
◀─ratchet│ TxRelayResponse{txid} │
└────────────────────────┘
(or {error})
later, as blocks arrive:
┌────────────────────────┐
◀─ratchet│ TxConfirmation │
│ {txid, depth: 1..3} │
└────────────────────────┘</div>
<p>The binary framing in <code>mesh/bitcoin_relay.rs</code> is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.</p>
<h3>LightningRelay (10) → LightningRelayResponse (11)</h3>
<p>Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns <code>payment_hash</code> or an error. Invoices are often long enough to chunk.</p>
<h3>Invoice (2) and PsbtHash (3)</h3>
<p>These are <em>not</em> relays — they're peer-to-peer handoffs. <code>Invoice</code> delivers a BOLT11 to be paid by the recipient. <code>PsbtHash</code> carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.</p>
<h3>BlockHeader (7)</h3>
<p>Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via <code>SEND_CHANNEL_TXT_MSG</code>) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.</p>
<h2 id="msg-safety">Alerts, Coordinates, Dead-Man</h2>
<h3>Alert (type 1)</h3>
<p>Envelope: <code>{kind, message, sender_contact_id}</code>. Kinds include <code>Emergency</code> and <code>Deadman</code>. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).</p>
<h3>Dead-man switch</h3>
<p>A background task in <code>mesh/alerts.rs</code> sends a <code>Deadman</code> alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.</p>
<h3>Coordinate (type 4)</h3>
<p>Envelope: <code>{lat, lon, accuracy_m}</code> with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.</p>
<h3>ChannelInvite (type 21)</h3>
<p>Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via <code>SEND_CHANNEL_TXT_MSG</code>.</p>
<h2 id="msg-identity">Identity, PrekeyBundle, ContactCard</h2>
<h3>Identity broadcast (marker 0x01, ARCHY:2/3)</h3>
<p>The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed <code>0x01</code>. This is how peers discover each other. The payload encodes protocol version (<code>ARCHY:2</code> or <code>ARCHY:3</code>) and the raw pubkey. Carried by <code>CMD_SEND_SELF_ADVERT</code> (<code>0x07</code>).</p>
<h3>PrekeyBundle (type 5) and SessionInit (type 6)</h3>
<p>X3DH handshake. <code>PrekeyBundle</code> advertises a signed prekey; <code>SessionInit</code> consumes it to derive the initial ratchet root key. Both ride on <code>0xEE</code> (static-key encryption), because the ratchet session they're creating doesn't yet exist.</p>
<h3>ContactCard (type 22)</h3>
<p>A shareable card containing <code>{did, onion_address, pubkey, display_name}</code>. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.</p>
<hr>
<h2 id="rpc">RPC API — what callers actually invoke</h2>
<p>Every user-facing action goes through the RPC dispatcher (<code>api/rpc/dispatcher.rs</code>, lines 287+) and ends in <code>api/rpc/mesh/typed_messages.rs</code>. The tables below show the public surface.</p>
<h3>Core commands</h3>
<table>
<thead><tr><th>RPC</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><code>mesh.status</code></td><td>Device info, peer count, enabled state</td></tr>
<tr><td><code>mesh.peers</code></td><td>List all discovered peers with RSSI / SNR / hop count</td></tr>
<tr><td><code>mesh.messages</code></td><td>Retrieve stored mesh messages</td></tr>
<tr><td><code>mesh.send</code></td><td>Send plain text to a specific peer</td></tr>
<tr><td><code>mesh.send-channel</code></td><td>Broadcast on a channel</td></tr>
<tr><td><code>mesh.broadcast</code></td><td>Mesh-wide announcement</td></tr>
<tr><td><code>mesh.configure</code></td><td>Set device params (name, power, channel)</td></tr>
<tr><td><code>mesh.debug-dump</code></td><td>Raw state for debugging</td></tr>
</tbody>
</table>
<h3>Rich message commands</h3>
<table>
<thead><tr><th>RPC</th><th>Msg Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td><code>mesh.send-invoice</code></td><td>Invoice (2)</td><td>Deliver BOLT11 to peer</td></tr>
<tr><td><code>mesh.send-coordinate</code></td><td>Coordinate (4)</td><td>Single frame, fixed-point</td></tr>
<tr><td><code>mesh.send-alert</code></td><td>Alert (1)</td><td>Emergency or deadman</td></tr>
<tr><td><code>mesh.send-content</code></td><td>ContentRef (19)</td><td>Stores blob, sends CID</td></tr>
<tr><td><code>mesh.fetch-content</code></td><td></td><td>Pulls blob via federation</td></tr>
<tr><td><code>mesh.send-psbt</code></td><td>PsbtHash (3)</td><td>Hash only, full PSBT via fetch</td></tr>
<tr><td><code>mesh.send-reply</code></td><td>Reply (13)</td><td>Quoted response</td></tr>
<tr><td><code>mesh.send-reaction</code></td><td>Reaction (14)</td><td>Emoji</td></tr>
<tr><td><code>mesh.send-read-receipt</code></td><td>ReadReceipt (15)</td><td>Cumulative "seen up to"</td></tr>
<tr><td><code>mesh.forward-message</code></td><td>Forward (16)</td><td>Wraps original + provenance</td></tr>
<tr><td><code>mesh.edit-message</code></td><td>Edit (17)</td><td>In-place text replacement</td></tr>
<tr><td><code>mesh.delete-message</code></td><td>Delete (18)</td><td>Tombstone</td></tr>
</tbody>
</table>
<h2 id="ui">User Interface</h2>
<p>The Vue side lives under <code>neode-ui/src/views/mesh/</code> with state in <code>stores/mesh.ts</code>. Notable panels:</p>
<div class="card-grid">
<div class="card-sm">
<h4>Mesh chat</h4>
<p>Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.</p>
</div>
<div class="card-sm">
<h4>MeshBitcoinPanel</h4>
<p>UI for TxRelay / LightningRelay submission and confirmation tracking.</p>
</div>
<div class="card-sm">
<h4>MeshDeadmanPanel</h4>
<p>Configure dead-man interval, pick recipients, show last heartbeat time.</p>
</div>
<div class="card-sm">
<h4>Unified inbox</h4>
<p>Federation and mesh chats appear side-by-side; the transport is invisible to the user.</p>
</div>
</div>
<h2 id="listener">Listener loop — how inbound traffic is decoded</h2>
<p>A long-running async task in <code>mesh/listener/mod.rs</code> owns the serial device and feeds events into the rest of the system.</p>
<div class="diagram">loop {
event = await serial_read()
match event {
<span class="green">PUSH_MESSAGES_WAITING</span> → send SYNC_NEXT_MESSAGE until empty
<span class="green">RESP_CONTACT_MSG_V3</span> → decode.rs extracts payload
→ match first byte:
<span class="highlight">0x00</span> plain text
<span class="highlight">0x01</span> identity → frames::parse_identity
<span class="highlight">0x02</span> typed CBOR plaintext
<span class="highlight">0xEE</span> → crypto::decrypt_static
<span class="highlight">0xDD</span> → session::load + ratchet::decrypt
→ dispatch.rs routes typed msg
to chat store / bitcoin relay /
alerts / presence / ...
<span class="green">RESP_CONTACT</span> → contact list update
<span class="green">RESP_SELF_INFO</span> → record our node_id
}
}</div>
<p>Chunk reassembly happens in <code>listener/session.rs</code>, keyed by <code>(sender_pubkey_prefix, chunk_id)</code>. Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.</p>
<h2 id="files">File Map</h2>
<table>
<thead><tr><th>File</th><th>Size</th><th>Role</th></tr></thead>
<tbody>
<tr><td><code>mesh/mod.rs</code></td><td>52 KB</td><td>Public API, send paths, federation integration</td></tr>
<tr><td><code>mesh/protocol.rs</code></td><td>26 KB</td><td>Frame encoding/decoding, command builders</td></tr>
<tr><td><code>mesh/serial.rs</code></td><td>15 KB</td><td>USB driver, device detection, handshake</td></tr>
<tr><td><code>mesh/crypto.rs</code></td><td>10 KB</td><td>X25519 ECDH, ChaCha20-Poly1305, HKDF</td></tr>
<tr><td><code>mesh/ratchet.rs</code></td><td>16 KB</td><td>Double Ratchet implementation</td></tr>
<tr><td><code>mesh/message_types.rs</code></td><td>23 KB</td><td>23 typed message discriminators + CBOR schemas</td></tr>
<tr><td><code>mesh/bitcoin_relay.rs</code></td><td>17 KB</td><td>TxRelay / LightningRelay binary framing</td></tr>
<tr><td><code>mesh/listener/dispatch.rs</code></td><td>29 KB</td><td>Typed-message routing into chat/relay/alerts</td></tr>
<tr><td><code>mesh/listener/session.rs</code></td><td>14 KB</td><td>Ratchet session persistence + chunk reassembly</td></tr>
<tr><td><code>mesh/x3dh.rs</code></td><td></td><td>Prekey / SessionInit bootstrap</td></tr>
<tr><td><code>mesh/outbox.rs</code></td><td></td><td>Retry queue for unacked sends</td></tr>
<tr><td><code>mesh/steganography.rs</code></td><td></td><td>Weather/sensor framing for deniable traffic</td></tr>
<tr><td><code>api/rpc/mesh/typed_messages.rs</code></td><td></td><td>All <code>mesh.*</code> RPC handlers</td></tr>
<tr><td><code>neode-ui/src/stores/mesh.ts</code></td><td>14 KB</td><td>Pinia store consumed by all mesh Vue views</td></tr>
</tbody>
</table>
<hr>
<h2>Summary scoreboard</h2>
<div class="score-grid">
<div class="score-card"><div class="score">23</div><div class="label">Message types</div></div>
<div class="score-card"><div class="score">160</div><div class="label">Bytes / frame</div></div>
<div class="score-card"><div class="score">2</div><div class="label">Transports</div></div>
<div class="score-card"><div class="score">5</div><div class="label">Wire markers</div></div>
<div class="score-card"><div class="score">~6k</div><div class="label">LoC in mesh/</div></div>
<div class="score-card"><div class="score">FS</div><div class="label">Forward-secure</div></div>
</div>
<div class="callout callout-success">
<strong>Bottom line.</strong> Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link.
</div>
</main>
</body>
</html>
@@ -0,0 +1,84 @@
# Replace Intro & Dashboard Backgrounds
To change the intro splash and dashboard tab backgrounds **without touching any code**, overwrite these files with your own assets. Use the exact names and locations below.
**Location:** All images go in `neode-ui/public/assets/img/`
**Format:** JPG recommended. Portrait or landscape; they use `background-size: cover` and `center center`.
---
## Intro Background
| Filename | Used for |
|----------|----------|
| **`bg-intro.jpg`** | Intro splash (alien typing + video poster + fallback), Dashboard default |
---
## Intro Video
| Filename | Where | Used for |
|----------|-------|----------|
| **`video-intro.mp4`** | `neode-ui/public/assets/video/` | Welcome Noderunner + logo, onboarding, login |
**Format:** MP4 (H.264). Keep under ~5MB for web. See `VIDEO_COMPRESSION_GUIDE.md` for optimization.
---
---
## Dashboard Tab Backgrounds
| Filename | Tab |
|----------|-----|
| **`bg-home.webp`** | Home |
| **`bg-web5.jpg`** | Web5 |
| **`bg-network.jpg`** | Server / Network |
| **`bg-settings.webp`** | Settings |
| **`bg-myapps.webp`** | My Apps |
| **`bg-appstore.webp`** | App Store / Marketplace |
| **`bg-cloud.webp`** | Cloud |
| **`bg-intro.jpg`** | Default (also intro) |
| **`bg-intro-3.jpg`** | Alternate layer during transitions |
---
## Intro Flow Backgrounds (onboarding)
| Filename | Used for |
|----------|----------|
| **`bg-intro-1.webp`** | Onboarding done, login |
| **`bg-intro-2.jpg`** | Onboarding verify |
| **`bg-intro-3.jpg`** | Onboarding path, dashboard transition layer |
| **`bg-intro-4.webp`** | Onboarding options |
| **`bg-intro-5.webp`** | Onboarding did |
| **`bg-intro-6.webp`** | Onboarding backup |
---
## Quick Reference
| Asset | Full path |
|-------|-----------|
| Intro image | `neode-ui/public/assets/img/bg-intro.jpg` |
| Intro video | `neode-ui/public/assets/video/video-intro.mp4` |
| Home | `neode-ui/public/assets/img/bg-home.webp` |
| Web5 | `neode-ui/public/assets/img/bg-web5.jpg` |
| Network | `neode-ui/public/assets/img/bg-network.jpg` |
| Settings | `neode-ui/public/assets/img/bg-settings.webp` |
| My Apps | `neode-ui/public/assets/img/bg-myapps.webp` |
| App Store | `neode-ui/public/assets/img/bg-appstore.webp` |
| Cloud | `neode-ui/public/assets/img/bg-cloud.webp` |
| Default | `neode-ui/public/assets/img/bg-intro.jpg` |
| Transition | `neode-ui/public/assets/img/bg-intro-3.jpg` |
| Intro 16 | `neode-ui/public/assets/img/bg-intro-1.webp``bg-intro-6.webp` (intro-2 and intro-3 remain `.jpg` — WebP came out larger for those) |
---
## Steps to Replace
1. Put your images in `neode-ui/public/assets/img/` with the exact filenames above.
2. Put your video in `neode-ui/public/assets/video/video-intro.mp4`.
3. Run `npm run build` (or deploy) so the new assets are included.
No code changes required.
@@ -0,0 +1,23 @@
# Welcome Noderunner Speech
The intro plays a sci-fi female voice saying "Welcome Noderunner" as the text types in.
## Generate the audio (ElevenLabs)
1. Get a free API key at [elevenlabs.io](https://elevenlabs.io) (free tier: 10k chars/month)
2. Run:
```bash
cd neode-ui
ELEVENLABS_API_KEY=your_key npm run generate-welcome-speech
```
3. Commit `welcome-noderunner.mp3` to the repo
## Custom sci-fi voice
Browse [ElevenLabs Voice Library](https://elevenlabs.io/voice-library) and search for "sci-fi", "AI", "robot", or "character". Copy the voice ID from the URL or voice settings, then:
```bash
ELEVENLABS_API_KEY=your_key ELEVENLABS_VOICE_ID=voice_id npm run generate-welcome-speech
```
Recommended: "The Digital Oracle", "The Friendly AI Assistant", or similar character voices from the Synthetic/Character categories.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,11 @@
# Icon Attribution
- `barbarian.svg`, `batteries.svg` — from **game-icons.net**
(https://game-icons.net), by Lorc, Delapouite & contributors,
licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/).
- Pixel-style icons (`save.svg`, `paint-bucket.svg`, `fill-half.svg`,
`cloud-moon.svg`, `debug-off.svg`, `cloud-done.svg`) — from
**pixelarticons** by Gerrit Halfmann (https://github.com/halfmage/pixelarticons),
MIT License.
- All other icons are original Archipelago artwork (MIT, see repository
LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><title xmlns="">batteries</title><path fill="currentColor" d="M168.063 21.844c-25.008 0-47.713 5.09-64.97 13.968c-16.938 8.716-29.722 21.962-30.187 38.626h-.03v93.625C59.258 180.325 46.9 197.92 37.75 219.032c-9.94 22.934-14.284 45.82-13 65.187c1.25 18.84 8.173 35.74 23 42.905l.344.156c.302.143.598.274.906.408l23.875 10.343v108.814h.03c.675 16.458 13.396 29.547 30.19 38.187c17.257 8.88 39.97 13.97 64.968 13.97c24.996 0 47.71-5.09 64.968-13.97c16.794-8.64 29.515-21.728 30.19-38.186h.03V420.56l42.844 18.563l.22.094l74.56 32.31l7.72 3.345l.844.375v-.03c15.48 6.212 32.73-.264 47.468-12.345c15.01-12.302 28.71-31.118 38.656-54.063c9.946-22.944 14.315-45.823 13.032-65.187c-1.26-19.01-8.33-36.01-23.438-43.063v-.03l-8.562-3.72L382.03 264.5l-118.78-51.47V76.97c.025-.53.03-1.06.03-1.595c0-.315-.02-.625-.03-.938c-.465-16.663-13.248-29.91-30.188-38.624c-17.256-8.88-39.992-13.97-65-13.97zM140.25 43.062c.03-.005.064.006.094 0c-6.743 3.237-10.906 7.637-10.906 12.5c0 9.93 17.292 17.97 38.625 17.97s38.625-8.04 38.625-17.97c0-4.863-4.164-9.263-10.907-12.5c11.11 2.093 20.927 5.366 28.72 9.376c13.818 7.11 20.094 15.646 20.094 22.937c0 7.29-6.276 15.797-20.094 22.906c-13.818 7.11-34.028 11.907-56.438 11.907s-42.62-4.797-56.437-11.906c-13.818-7.108-20.063-15.614-20.063-22.905c0-7.29 6.245-15.828 20.063-22.938c7.772-3.998 17.554-7.28 28.625-9.374zM72.875 195.656v122l-16.438-7.125c-6.678-2.894-12.003-12.02-13.03-27.53c-1.03-15.51 2.593-35.983 11.5-56.53c5.082-11.73 11.316-22.17 17.968-30.814zm171.688 1.75V445.47c0 7.278-6.24 15.825-20.063 22.936s-34.042 11.906-56.438 11.906c-22.395 0-42.615-4.794-56.437-11.906c-13.822-7.11-20.063-15.658-20.063-22.937V200.31L145.375 280l-36.844 12.875L229.75 420.25l-51.72-105.78l23.907-11.845l-40.156-84.188c2.082.073 4.168.125 6.282.125c24.997 0 47.71-5.09 64.97-13.968c4.134-2.128 8.008-4.537 11.53-7.188m18.687 36l79 34.25l-79 14.78zm187.97 82.78c2.094.035 3.983.433 5.655 1.158c6.69 2.9 12.035 12.026 13.063 27.53c.577 8.715-.333 18.995-2.813 29.97c-.305-7.425-2.682-12.95-7.125-14.875c-9.11-3.95-23.39 8.707-31.875 28.28s-7.953 38.645 1.156 42.594c4.45 1.928 10.12-.104 15.75-4.97c-6.316 9.33-13.207 17.023-19.967 22.563c-12.02 9.85-22.342 12.18-29.032 9.282c-6.688-2.9-12.034-12.027-13.06-27.533c-1.03-15.505 2.618-35.94 11.53-56.5s21.357-37.21 33.375-47.062c9.014-7.388 17.058-10.537 23.344-10.438zm-153.5 8.658c-.9 1.89-1.78 3.8-2.626 5.75c-9.94 22.935-14.315 45.79-13.03 65.156c.307 4.65.966 9.173 1.998 13.47l-20.812-9v-74.126z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">bitcoin</title><path fill="currentColor" d="M13 3h2v2h2v2H9v4h8v2H9v4h8v2h-2v2h-2v-2h-2v2H9v-2H5v-2h2v-4H5v-2h2V7H5V5h4V3h2v2h2zm4 14v-4h2v4zm0-6V7h2v4z"/></svg>

After

Width:  |  Height:  |  Size: 262 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-done</title><path fill="currentColor" d="M16 4h-6v2H8v2H4v2H2v2H0v6h2v2h20v-2h2v-6h-2v-2h-2V8h-2V6h-2zm0 2v2h2v4h4v6H2v-6h2v-2h4V8h2V6zm-6 6H8v2h2v2h2v-2h2v-2h2v-2h-2v2h-2v2h-2z"/></svg>

After

Width:  |  Height:  |  Size: 293 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">cloud-moon</title><path fill="currentColor" d="M18 2h-8v2H8v2H6v4h2V6h2V4h4v2h-2v4h2v2h4v-2h2v4h-2v2h2v-2h2V6h-2v2h-2v2h-4V6h2V4h2zM8 14v-2h4v2zm0 2v-2H4v2H2v4h2v2h10v-2h2v-4h-2v-2h-2v2h2v4H4v-4zm0 0h2v2H8z"/></svg>

After

Width:  |  Height:  |  Size: 316 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">debug-off</title><path fill="currentColor" d="M16 2h2v2h-2zm4 7h-2V6h-2V4h-2v2h-2v2h4v5h2v2h4v-2h-4v-2h2zm0 0V7h2v2zM8 20v-9H6V9H4V7H2v2h2v2h2v2H2v2h4v2H4v2H2v2h2v-2h2v3h10v-2zm2-5h2v2h-2zM2 2h2v2H2zm4 4H4V4h2zm2 2H6V6h2zm2 2H8V8h2zm0 0v2h2v2h2v2h2v2h2v2h2v2h2v-2h-2v-2h-2v-2h-2v-2h-2v-2h-2v-2z"/></svg>

After

Width:  |  Height:  |  Size: 404 B

@@ -0,0 +1,31 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bgGrad" cx="50%" cy="45%" r="55%">
<stop offset="0%" stop-color="#1a1a1a"/>
<stop offset="100%" stop-color="#050505"/>
</radialGradient>
<linearGradient id="borderGrad" x1="0" y1="0" x2="1024" y2="1024" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.55"/>
<stop offset="50%" stop-color="#888888" stop-opacity="0.25"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.7"/>
</linearGradient>
<radialGradient id="glassGlow" cx="35%" cy="30%" r="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.06"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Background circle -->
<circle cx="512" cy="512" r="484" fill="url(#bgGrad)"/>
<!-- Glass glow highlight (top-left) -->
<circle cx="512" cy="512" r="460" fill="url(#glassGlow)"/>
<!-- Gradient border ring (white-to-dark, like logo-gradient-border CSS) -->
<circle cx="512" cy="512" r="484" fill="none" stroke="url(#borderGrad)" stroke-width="16"/>
<!-- The "A" logo — pixel-art style, centered and scaled -->
<g transform="translate(160, 168) scale(0.69)">
<path d="M357.614 388.936V318H428.621V388.936H357.614ZM436.152 388.936V318H508.234V388.936H436.152ZM515.766 388.936V318H587.848V388.936H515.766ZM595.379 388.936V318H666.386V388.936H595.379ZM595.379 468.471V396.46H666.386V468.471H595.379ZM673.917 468.471V396.46H746V468.471H673.917ZM278 548.006V475.994H350.083V548.006H278ZM357.614 548.006V475.994H428.621V548.006H357.614ZM436.152 548.006V475.994H508.234V548.006H436.152ZM515.766 548.006V475.994H587.848V548.006H515.766ZM595.379 548.006V475.994H666.386V548.006H595.379ZM673.917 548.006V475.994H746V548.006H673.917ZM278 626.465V555.529H350.083V626.465H278ZM357.614 626.465V555.529H428.621V626.465H357.614ZM595.379 626.465V555.529H666.386V626.465H595.379ZM673.917 626.465V555.529H746V626.465H673.917ZM357.614 706V633.989H428.621V706H357.614ZM436.152 706V633.989H508.234V706H436.152ZM515.766 706V633.989H587.848V706H515.766ZM595.379 706V633.989H666.386V706H595.379Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24"><title xmlns="">fill-half</title><path fill="currentColor" d="M9 2h2v2H9zm4 4V4h-2v2H9v2H7v2H5v2H3v2h2v2h2v2h2v2h2v2h2v-2h2v-2h2v-2h2v6h2V12h-2v-2h-2V8h-2V6zm0 0v2h2v2h2v2h2v2H5v-2h2v-2h2V8h2V6z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

Some files were not shown because too many files have changed in this diff Show More