Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 1df8903f87
1561 changed files with 332544 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! 🎨⚡
+48
View File
@@ -0,0 +1,48 @@
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
# 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)"]
+65
View File
@@ -0,0 +1,65 @@
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
# Expose port
EXPOSE 80
# 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!** 🎨⚡
+155
View File
@@ -0,0 +1,155 @@
# 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)
- **Production deploy**: via `scripts/deploy-to-target.sh --live`
## License
MIT
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+126
View File
@@ -0,0 +1,126 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* 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.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-21a80088'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.nnkdothias"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//]
}));
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/\/rpc\/v1\/.*/i, new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 300
})]
}), 'GET');
workbox.registerRoute(/\/assets\/.*/i, new workbox.CacheFirst({
"cacheName": "assets-cache-v2",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 2592000
})]
}), 'GET');
}));
File diff suppressed because it is too large Load Diff
+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;'
+196
View File
@@ -0,0 +1,196 @@
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: reverse-proxy the real site same-origin, strip framing headers,
# and rewrite its absolute asset paths (/assets, /, src, href) to the
# /app/indeedhub/ prefix so the SPA loads inside the iframe.
location ^~ /app/indeedhub/ {
proxy_pass https://indee.tx1138.com/;
proxy_http_version 1.1;
proxy_set_header Host indee.tx1138.com;
proxy_set_header Accept-Encoding "";
proxy_ssl_server_name on;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Content-Security-Policy-Report-Only;
sub_filter_types text/html text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/indeedhub/';
sub_filter 'src="/' 'src="/app/indeedhub/';
sub_filter "href='/" "href='/app/indeedhub/";
sub_filter "src='/" "src='/app/indeedhub/";
sub_filter 'from"/' 'from"/app/indeedhub/';
sub_filter 'url(/' 'url(/app/indeedhub/';
}
# 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
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+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())
}
}
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"
+30
View File
@@ -0,0 +1,30 @@
<!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" />
<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>
+5235
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)."
+12493
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.100-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
"stop": "./stop-dev.sh",
"test": "vitest run",
"test:watch": "vitest",
"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": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"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",
"qrcode": "^1.5.4",
"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://192.168.1.228',
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.
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

+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="">github</title><path fill="currentColor" d="M5 2h4v2H7v2H5zm0 10H3V6h2zm2 2H5v-2h2zm2 2v-2H7v2H3v-2H1v2h2v2h4v4h2v-4h2v-2zm0 0v2H7v-2zm6-12v2H9V4zm4 2h-2V4h-2V2h4zm0 6V6h2v6zm-2 2v-2h2v2zm-2 2v-2h2v2zm0 2h-2v-2h2zm0 0h2v4h-2z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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