Archipelago — open-source initial import
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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! 🎨⚡
|
||||
@@ -0,0 +1,52 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache wget curl docker-cli
|
||||
|
||||
# Copy package files
|
||||
COPY neode-ui/package*.json ./
|
||||
|
||||
# Install Node dependencies (need all deps for mock backend)
|
||||
RUN npm install
|
||||
|
||||
# Copy application code
|
||||
COPY neode-ui/ ./
|
||||
|
||||
# Sibling assets the mock backend reads relative to /app (../docker, ../demo):
|
||||
# the Bitcoin UI mock shell and the curated cloud files (demo/files drop-ins +
|
||||
# the committed demo/content library — both are scanned by loadDemoDiskFiles).
|
||||
COPY docker/bitcoin-ui /docker/bitcoin-ui
|
||||
COPY docker/electrs-ui /docker/electrs-ui
|
||||
COPY docker/lnd-ui /docker/lnd-ui
|
||||
COPY docker/fedimint-ui /docker/fedimint-ui
|
||||
COPY demo/files /demo/files
|
||||
COPY demo/content /demo/content
|
||||
# Peer catalog media (posters/covers/photos for content.browse-peer mocks) —
|
||||
# deliberately OUTSIDE demo/content so it doesn't appear as the visitor's own
|
||||
# cloud files.
|
||||
COPY demo/peer-media /demo/peer-media
|
||||
|
||||
# This image only ever serves the public demo — scrub the private release/registry
|
||||
# server address from everything it serves (mock data, catalog.json, demo assets)
|
||||
# and fail the build if any occurrence survives.
|
||||
RUN find /app /docker /demo -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' \
|
||||
-o -name '*.css' -o -name '*.html' -o -name '*.json' -o -name '*.md' -o -name '*.txt' \
|
||||
-o -name '*.yml' -o -name '*.yaml' \) -not -path '*/node_modules/*' \
|
||||
-exec sed -i \
|
||||
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
|
||||
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
|
||||
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
|
||||
if grep -rq '146\.59\.87\.168' /app/mock-backend.js /app/public /docker /demo; then \
|
||||
echo 'LEAK: release-server IP still in demo image'; exit 1; fi
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5959
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=15s --retries=5 --start-period=180s \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:5959/health || exit 1
|
||||
|
||||
# Start the mock backend with error handling
|
||||
CMD ["sh", "-c", "node mock-backend.js 2>&1 || (echo 'ERROR: Backend failed to start'; cat /app/package.json; ls -la /app; sleep infinity)"]
|
||||
@@ -0,0 +1,68 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY neode-ui/package*.json ./
|
||||
|
||||
# Install all dependencies (including dev)
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY neode-ui/ ./
|
||||
|
||||
# Clean up backup files and large unused assets before build
|
||||
RUN find public/assets -name "*backup*" -type f -delete || true && \
|
||||
find public/assets -name "*1.47mb*" -type f -delete || true && \
|
||||
find public/assets -name "bg-*.mp4" -type f -delete || true
|
||||
|
||||
# Build the Vue app (skip type checking, just build)
|
||||
ENV DOCKER_BUILD=true
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Public-demo build flag — inlined into the bundle (import.meta.env.VITE_DEMO).
|
||||
# Enables the per-day intro replay, the "entertoexit" login hint, and other
|
||||
# demo-only UI affordances. Override with --build-arg VITE_DEMO=0 for a plain build.
|
||||
ARG VITE_DEMO=1
|
||||
ENV VITE_DEMO=$VITE_DEMO
|
||||
|
||||
# Use npm script which handles build better
|
||||
RUN npm run build:docker || (echo "Build failed! Listing files:" && ls -la && echo "Checking vite config:" && cat vite.config.ts && exit 1)
|
||||
|
||||
# Demo builds must not leak the private release/registry server address anywhere
|
||||
# in the served bundle (JS constants, catalog.json, prose). Replace every
|
||||
# occurrence with a non-routable placeholder, then fail the build if any slipped
|
||||
# through. (The companion-QR URL is handled in source — it needs a URL that works.)
|
||||
RUN if [ "$VITE_DEMO" = "1" ] || [ "$VITE_DEMO" = "true" ]; then \
|
||||
find dist -type f \( -name '*.js' -o -name '*.mjs' -o -name '*.css' -o -name '*.html' \
|
||||
-o -name '*.json' -o -name '*.map' -o -name '*.txt' -o -name '*.webmanifest' \) \
|
||||
-exec sed -i \
|
||||
-e 's#146\.59\.87\.168:3000/lfg2025#registry.demo.internal/archy#g' \
|
||||
-e 's#146\.59\.87\.168:3000#registry.demo.internal#g' \
|
||||
-e 's#146\.59\.87\.168#registry.demo.internal#g' {} + && \
|
||||
if grep -rq '146\.59\.87\.168' dist; then echo 'LEAK: release-server IP still in dist'; exit 1; fi; \
|
||||
fi
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files to nginx
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy AIUI pre-built dist
|
||||
COPY demo/aiui/ /usr/share/nginx/html/aiui/
|
||||
|
||||
# Copy nginx config template and entrypoint
|
||||
COPY neode-ui/docker/nginx-demo.conf /etc/nginx/nginx.conf.template
|
||||
COPY neode-ui/docker/docker-entrypoint.sh /docker-entrypoint-custom.sh
|
||||
RUN chmod +x /docker-entrypoint-custom.sh
|
||||
|
||||
# IndeeHub demo sign-in seeder, injected by the :2101 whole-origin proxy
|
||||
# (nginx-demo.conf). Demo image only — never in real-node artifacts.
|
||||
COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js
|
||||
|
||||
# Expose ports (80 = demo UI, 2101 = IndeeHub whole-origin demo proxy)
|
||||
EXPOSE 80 2101
|
||||
|
||||
# Substitute ANTHROPIC_API_KEY at runtime, then start nginx
|
||||
ENTRYPOINT ["/docker-entrypoint-custom.sh"]
|
||||
@@ -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!** 🎨⚡
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Archipelago Web UI
|
||||
|
||||
Vue 3 + TypeScript + Vite + Tailwind CSS + Pinia
|
||||
|
||||
The web interface for Archipelago — a self-sovereign Bitcoin Node OS.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd neode-ui
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Visit **http://localhost:8100** — login with password: `password123`
|
||||
|
||||
This starts:
|
||||
- Mock backend on port 5959 (no Docker required)
|
||||
- Vite dev server on port 8100 with HMR
|
||||
|
||||
Stop with `npm stop`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
neode-ui/
|
||||
├── src/
|
||||
│ ├── api/ # RPC client (rpc-client.ts), WebSocket, container client
|
||||
│ ├── stores/ # Pinia stores (app, container, appLauncher, monitoring)
|
||||
│ ├── views/ # Page components (Dashboard, Marketplace, Settings, etc.)
|
||||
│ ├── components/ # Reusable components (SplashScreen, AppSession, etc.)
|
||||
│ ├── router/ # Vue Router configuration
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── style.css # Global styles + Tailwind utilities
|
||||
├── public/assets/ # Static assets (images, fonts, app icons, audio)
|
||||
├── mock-backend.js # Mock backend server (simulates Rust backend)
|
||||
├── docker/ # Docker configs (nginx, entrypoint)
|
||||
└── vite.config.ts # Vite config with backend proxy
|
||||
```
|
||||
|
||||
## Dev Modes
|
||||
|
||||
The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
|
||||
|
||||
| Mode | Command | Behavior |
|
||||
|------|---------|----------|
|
||||
| **default** | `npm start` | Fully set up, login screen |
|
||||
| **existing** | `VITE_DEV_MODE=existing npm run dev:mock` | Same as default |
|
||||
| **setup** | `VITE_DEV_MODE=setup npm run dev:mock` | First-time password setup flow |
|
||||
| **onboarding** | `VITE_DEV_MODE=onboarding npm run dev:mock` | Post-setup onboarding flow |
|
||||
| **boot** | `npm run dev:boot` | 25s simulated boot sequence |
|
||||
|
||||
## Mock Backend
|
||||
|
||||
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
|
||||
|
||||
**Pre-installed apps** (always visible in My Apps):
|
||||
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
|
||||
|
||||
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
|
||||
|
||||
**Features simulated**:
|
||||
- Authentication (login, password change, TOTP 2FA)
|
||||
- System metrics (CPU, memory, disk — randomized for realism)
|
||||
- Node identity (DID, Nostr pubkey, Tor address)
|
||||
- Federation (3 mock nodes with apps, metrics, trust levels)
|
||||
- Mesh networking (4 LoRa peers, encrypted messaging, invoices)
|
||||
- Peer-to-peer messaging
|
||||
- FileBrowser API (mock file system with Music, Documents, Photos, Videos)
|
||||
- DWN sync status
|
||||
- Transport layer (mesh/LAN/Tor routing)
|
||||
- Notifications (5 realistic entries)
|
||||
- Claude AI chat proxy (requires `ANTHROPIC_API_KEY`)
|
||||
|
||||
**Container runtime**: If Docker/Podman is available, the mock backend will run real containers for installed apps. Otherwise, it simulates them.
|
||||
|
||||
## Demo Deployment (Portainer)
|
||||
|
||||
Deploy the demo via Docker Compose for showcasing:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.demo.yml build
|
||||
docker compose -f docker-compose.demo.yml up -d
|
||||
```
|
||||
|
||||
Or deploy through **Portainer Stacks**:
|
||||
1. Stacks > Add stack > name: `archy-demo`
|
||||
2. Web editor: paste `docker-compose.demo.yml` contents
|
||||
3. Add environment variable: `ANTHROPIC_API_KEY` (for Claude chat)
|
||||
4. Deploy
|
||||
|
||||
Access at **http://your-host:4848** — password: `password123`
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm start # Start mock backend + Vite (recommended)
|
||||
npm stop # Stop all servers
|
||||
npm run dev:mock # Same as start, without port cleanup
|
||||
npm run dev:boot # Boot mode (simulated startup delay)
|
||||
npm run backend:mock # Mock backend only
|
||||
npm run dev # Vite only (needs backend running separately)
|
||||
npm run dev:real # Vite with real Rust backend
|
||||
|
||||
npm run build # Production build (outputs to ../web/dist/neode-ui/)
|
||||
npm run build:docker # Build for Docker (no type checking)
|
||||
npm run type-check # TypeScript type checking
|
||||
npm test # Run tests
|
||||
```
|
||||
|
||||
## Design System
|
||||
|
||||
### Glass Classes
|
||||
|
||||
| Class | Use |
|
||||
|-------|-----|
|
||||
| `.glass-card` | Content containers, modals, panels |
|
||||
| `.glass-button` | ALL buttons (primary and secondary) |
|
||||
| `.path-option-card` | Interactive cards with hover lift |
|
||||
| `.info-card` | Status badges, metric displays |
|
||||
|
||||
### Tokens
|
||||
|
||||
- **Font**: Avenir Next (primary), Montserrat (`font-archipelago`)
|
||||
- **Glass**: `bg: rgba(0,0,0,0.60)`, `blur: 24px`, `border: rgba(255,255,255,0.22)`
|
||||
- **Accent**: `#fb923c` (Bitcoin orange), `#4ade80` (green), `#ef4444` (red)
|
||||
- **Text**: `rgba(255,255,255,0.9)` primary, `rgba(255,255,255,0.6)` muted
|
||||
|
||||
### Rules
|
||||
|
||||
- Global CSS classes in `style.css` only — never inline Tailwind in components
|
||||
- `.gradient-button` is **banned** — use `.glass-button`
|
||||
- All components use `<script setup lang="ts">`
|
||||
|
||||
## API
|
||||
|
||||
```typescript
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
await rpcClient.login('password')
|
||||
await rpcClient.startPackage('bitcoin')
|
||||
const metrics = await rpcClient.getMetrics()
|
||||
```
|
||||
|
||||
State management via Pinia stores. WebSocket patches applied automatically.
|
||||
|
||||
## Build Output
|
||||
|
||||
- **Dev build**: `../web/dist/neode-ui/`
|
||||
- **Docker build**: `dist/` (deployed to nginx)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,39 @@
|
||||
# Third-Party Licenses — neode-ui (web frontend)
|
||||
|
||||
Runtime dependencies bundled into the distributed web UI. Verified 2026-07-23.
|
||||
Dev-only tooling (Vite, Playwright, TypeScript, etc.) is not distributed and
|
||||
is not listed here. Note: the production bundler strips license header
|
||||
comments, so this file (and the fonts' adjacent license files) constitute the
|
||||
attribution shipped with the bundle; a build-time license-aggregation step is
|
||||
planned (see docs/LICENSE-COMPLIANCE-AUDIT.md).
|
||||
|
||||
| Package | Version | License |
|
||||
|---|---|---|
|
||||
| vue | 3.5.x | MIT |
|
||||
| vue-router | 4.6.x | MIT |
|
||||
| vue-i18n | 11.3.x | MIT |
|
||||
| pinia | 3.0.x | MIT |
|
||||
| d3 | 7.9.x | ISC |
|
||||
| leaflet | 1.9.x | BSD-2-Clause |
|
||||
| @vue-leaflet/vue-leaflet | 0.10.x | MIT |
|
||||
| dompurify | 3.4.x | MPL-2.0 OR Apache-2.0 (used under Apache-2.0) |
|
||||
| buffer | 6.0.x | MIT |
|
||||
| fast-json-patch | 3.1.x | MIT |
|
||||
| fuse.js | 7.1.x | Apache-2.0 |
|
||||
| qrcode | 1.5.x | MIT |
|
||||
| qr-scanner | 1.4.x | MIT |
|
||||
| qrloop | 1.4.x | MIT |
|
||||
|
||||
## Fonts
|
||||
|
||||
| Font | License | License file |
|
||||
|---|---|---|
|
||||
| Montserrat | SIL OFL 1.1 | public/assets/fonts/Montserrat/OFL.txt |
|
||||
| Open Sans | Apache-2.0 | public/assets/fonts/Open_Sans/LICENSE.txt |
|
||||
|
||||
## Vendored
|
||||
|
||||
- `public/assets/icon/` — see ATTRIBUTION.md in that directory
|
||||
(game-icons.net CC BY 3.0; pixelarticons MIT).
|
||||
- `public/assets/img/mesh-devices/` — Meshtastic project artwork, GPL-3.0;
|
||||
see ATTRIBUTION.md in that directory.
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;'
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* PUBLIC-DEMO-ONLY IndeeHub sign-in seeder.
|
||||
*
|
||||
* Served at /__demo/indee-demo-signin.js on the :2101 IndeeHub whole-origin
|
||||
* demo proxy (see nginx-demo.conf) and injected into the proxied site's HTML
|
||||
* <head> via sub_filter. It only ever runs on the demo's :2101 origin inside
|
||||
* the demo iframe — it never ships in real-node artifacts.
|
||||
*
|
||||
* THROWAWAY DEMO IDENTITY — NOT A SECRET. The embedded secp256k1 keypair was
|
||||
* freshly generated for the public demo (2026-07-29) and has never belonged
|
||||
* to any real user. Its whole purpose is to be a shared, public "demo
|
||||
* visitor" Nostr identity so the embedded IndeeHub boots signed in with no
|
||||
* login wall. Anyone extracting this key can only impersonate the demo
|
||||
* visitor, by design (threat T-gjd-01: accepted).
|
||||
*
|
||||
* How it works: IndeeHub's bundle (applesauce-accounts) restores accounts on
|
||||
* boot from localStorage key "indeedhub-accounts" (JSON array of serialized
|
||||
* accounts; a "nsec" private-key account deserializes as
|
||||
* { id, type: "nsec", pubkey, metadata, signer: { key: <hex sk> } }) and
|
||||
* activates the account whose id is stored under "indeedhub-active-account".
|
||||
* This classic script executes before the SPA's deferred module bundle, so
|
||||
* seeding here is visible to that boot-restore. Seeding is idempotent: an
|
||||
* existing non-empty account list is never overwritten.
|
||||
*/
|
||||
;(function () {
|
||||
'use strict'
|
||||
|
||||
var ACCOUNTS_KEY = 'indeedhub-accounts'
|
||||
var ACTIVE_KEY = 'indeedhub-active-account'
|
||||
|
||||
// Throwaway demo keypair (see header — public by design, not a secret).
|
||||
var DEMO_SK_HEX = 'ce2ffa96f99968beffc789cbba5d8b52f4a3020454dcaf77c2b553961bf5a8c9'
|
||||
var DEMO_PK_HEX = '7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31'
|
||||
var DEMO_ACCOUNT_ID = 'archy-demo-visitor'
|
||||
|
||||
try {
|
||||
var existing = null
|
||||
try {
|
||||
existing = JSON.parse(localStorage.getItem(ACCOUNTS_KEY))
|
||||
} catch (e) {
|
||||
existing = null
|
||||
}
|
||||
if (Array.isArray(existing) && existing.length > 0) return
|
||||
|
||||
var account = {
|
||||
id: DEMO_ACCOUNT_ID,
|
||||
type: 'nsec',
|
||||
pubkey: DEMO_PK_HEX,
|
||||
metadata: { name: 'Archy Demo' },
|
||||
signer: { key: DEMO_SK_HEX },
|
||||
}
|
||||
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify([account]))
|
||||
localStorage.setItem(ACTIVE_KEY, DEMO_ACCOUNT_ID)
|
||||
} catch (e) {
|
||||
// localStorage unavailable (e.g. blocked third-party storage) — the demo
|
||||
// visitor just sees IndeeHub's normal signed-out state.
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,226 @@
|
||||
worker_processes 1;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log /var/log/nginx/access.log;
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Allow large uploads globally (filebrowser, etc.)
|
||||
client_max_body_size 0;
|
||||
|
||||
# WebSocket upgrade passthrough (mempool live data, etc.)
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /rpc/v1 {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections
|
||||
location /ws {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Proxy public assets from backend
|
||||
location /public {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy REST API requests
|
||||
location /rest {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# ElectrumX UI status (polled by the electrs-ui shell)
|
||||
location /electrs-status {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# LND UI endpoints (polled by the lnd-ui shell)
|
||||
location /proxy/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /lnd-connect-info {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy FileBrowser API to mock backend (demo mode)
|
||||
# ^~ on every /app/ prefix: the .css/.js/.img cache regex below must
|
||||
# never swallow app-shell assets (they live on the backend, not in the
|
||||
# web root — without ^~ nginx prefers the regex and 404s them).
|
||||
location ^~ /app/filebrowser/ {
|
||||
client_max_body_size 10G;
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# IndeeHub is NOT proxied under a /app/indeedhub/ path prefix — the
|
||||
# old sub_filter path-rewrite approach broke the SPA's runtime-built
|
||||
# absolute-root asset URLs. Instead, a dedicated WHOLE-ORIGIN reverse
|
||||
# proxy of https://indee.tx1138.com listens on :2101 (see the second
|
||||
# server block below): the SPA sees itself at '/' so every asset and
|
||||
# router path just works, framing headers are stripped, and a demo
|
||||
# sign-in seed script is injected. useDemoIntro.demoAppUrl points the
|
||||
# in-app iframe at http://<demo-host>:2101/.
|
||||
|
||||
# Mempool is NOT proxied upstream anymore — the mock backend serves a
|
||||
# branded placeholder page for it (see DEMO_APP_PAGES in mock-backend.js),
|
||||
# so /app/mempool/ falls through to the generic /app/ location below.
|
||||
|
||||
# Proxy every other app UI (/app/<id>/) to the mock backend, which serves
|
||||
# the per-app mock UIs (bitcoin-ui, electrumx, lnd, fedimint) and the
|
||||
# generic "Not available in the demo" notice for the rest.
|
||||
location ^~ /app/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Serve AIUI SPA
|
||||
location /aiui/ {
|
||||
alias /usr/share/nginx/html/aiui/;
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
location ~* /aiui/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Proxy AIUI API requests (web-search, etc.) to backend
|
||||
location /api/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy Ollama (local AI) requests to backend
|
||||
location /aiui/api/ollama/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Proxy Claude API requests to backend (which handles API key + streaming)
|
||||
location /aiui/api/claude/ {
|
||||
proxy_pass http://neode-backend:5959;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Serve static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets (media too — the intro video/audio are versioned
|
||||
# with ?v=N query busters, so immutable is safe)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|mp4|webm|mp3|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# ── IndeeHub whole-origin demo proxy (:2101) ────────────────────────────
|
||||
# Pure reverse proxy of the LIVE https://indee.tx1138.com site on its own
|
||||
# port — no path prefix, no URL rewriting, so the SPA's absolute-root
|
||||
# asset/router paths work untouched. The upstream's X-Frame-Options
|
||||
# (SAMEORIGIN) and any CSP are stripped so the demo can embed it in the
|
||||
# in-app iframe session, and a PUBLIC-DEMO-ONLY sign-in seed script is
|
||||
# injected into the HTML <head> (it seeds a labelled throwaway demo Nostr
|
||||
# account into the :2101 origin's isolated localStorage, so IndeeHub boots
|
||||
# signed in). Upstream is pinned to a single fixed hostname — this cannot
|
||||
# be used as an open proxy.
|
||||
server {
|
||||
listen 2101;
|
||||
server_name _;
|
||||
|
||||
# Demo sign-in seeder, served same-origin to the proxied SPA.
|
||||
location = /__demo/indee-demo-signin.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass https://indee.tx1138.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name indee.tx1138.com;
|
||||
proxy_set_header Host indee.tx1138.com;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
# Allow embedding in the demo's iframe session.
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
|
||||
# HTML injection: upstream must not compress or sub_filter no-ops.
|
||||
# (sub_filter applies to text/html by default — exactly what we want.)
|
||||
proxy_set_header Accept-Encoding "";
|
||||
sub_filter_once on;
|
||||
sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* End-to-end verification of the FIRST-VISIT cinematic:
|
||||
* splash (tap logo → alien typing → Welcome Noderunner + speech + synthwave
|
||||
* → Archipelago logo) → onboarding intro → login (video background, finale
|
||||
* armed) → dashboard full reveal (background zoom + interface assembly +
|
||||
* oomph) → welcome typing — and that a SECOND login stays deliberately
|
||||
* low-key.
|
||||
*
|
||||
* Run against the local demo stack:
|
||||
* DEMO=1 node mock-backend.js (port 5959)
|
||||
* VITE_DEMO=1 npx vite --port 8100
|
||||
* ARCHY_BASE_URL=http://localhost:8100 npx playwright test e2e/intro-experience.spec.ts
|
||||
*
|
||||
* Audio can't be heard headless, so every HTMLMediaElement.play() and
|
||||
* WebAudio oscillator start is recorded into window.__audioLog via an init
|
||||
* script — the assertions check the right cues fired at the right phases.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
// Real Chrome (new headless): the bundled headless-shell has no working video
|
||||
// pipeline (0 fps, ~80% dropped frames), which makes every media assertion
|
||||
// meaningless there. Requires Google Chrome installed.
|
||||
test.use({ channel: 'chrome' })
|
||||
|
||||
const BASE = process.env.ARCHY_BASE_URL ?? 'http://localhost:8100'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__audioLog: string[]
|
||||
__videoStats: { stalls: number; waiting: number; dropped: number; total: number; readyStateAtPlay: number }
|
||||
__revealSeen: { zoom?: boolean; glass?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
async function instrumentAudioAndVideo(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
window.__audioLog = []
|
||||
window.__videoStats = { stalls: 0, waiting: 0, dropped: 0, total: 0, readyStateAtPlay: -1 }
|
||||
|
||||
// The dashboard reveal classes live only ~8s; a slow run can burn that
|
||||
// window between two sequential expect() polls. Record their appearance
|
||||
// the moment it happens instead, and let the test assert on the record.
|
||||
window.__revealSeen = {}
|
||||
new MutationObserver(() => {
|
||||
if (!window.__revealSeen.zoom && document.querySelector('.zoom-reveal-bg')) window.__revealSeen.zoom = true
|
||||
if (!window.__revealSeen.glass && document.querySelector('.glass-throw-active')) window.__revealSeen.glass = true
|
||||
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
const origPlay = HTMLMediaElement.prototype.play
|
||||
HTMLMediaElement.prototype.play = function (...args) {
|
||||
const src = (this.currentSrc || this.src || (this.querySelector?.('source') as HTMLSourceElement | null)?.src || 'unknown')
|
||||
window.__audioLog.push(`media-play:${src.split('/').pop()}`)
|
||||
if (this.tagName === 'VIDEO') {
|
||||
const v = this as HTMLVideoElement
|
||||
if (window.__videoStats.readyStateAtPlay === -1) window.__videoStats.readyStateAtPlay = v.readyState
|
||||
v.addEventListener('stalled', () => { window.__videoStats.stalls++ })
|
||||
v.addEventListener('waiting', () => { window.__videoStats.waiting++ })
|
||||
}
|
||||
return origPlay.apply(this, args)
|
||||
}
|
||||
|
||||
// WebAudio: oscillator/buffer starts = synth pops, oomph layers, synthwave.
|
||||
const OrigOsc = OscillatorNode.prototype.start
|
||||
OscillatorNode.prototype.start = function (...args) {
|
||||
window.__audioLog.push('osc-start')
|
||||
return OrigOsc.apply(this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function freshVisit(page: Page) {
|
||||
await page.goto(BASE + '/')
|
||||
await page.evaluate(() => { localStorage.clear(); sessionStorage.clear() })
|
||||
await page.goto(BASE + '/')
|
||||
}
|
||||
|
||||
/** Walk the full splash from tap-to-start through completion (real time). */
|
||||
async function runSplash(page: Page, { skip }: { skip: boolean }) {
|
||||
// Phase 1: tap-to-start — "Enter to Exit" + logo (the overlay animates away
|
||||
// on click, so don't wait for post-click actionability)
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
await page.locator('.tap-to-start-logo').click({ noWaitAfter: true, force: true })
|
||||
|
||||
// Phase 2: alien typing begins (first line types out)
|
||||
await expect(page.getByText('In the future there will be 3 types', { exact: false }))
|
||||
.toBeVisible({ timeout: 10_000 })
|
||||
|
||||
if (skip) {
|
||||
await page.getByRole('button', { name: 'Skip Intro' }).click({ noWaitAfter: true })
|
||||
} else {
|
||||
// Let all four lines type out for real (~20s)
|
||||
await expect(page.getByText('And Noderunners...', { exact: false })).toBeVisible({ timeout: 45_000 })
|
||||
}
|
||||
|
||||
// Phase 3+4 (Welcome Noderunner → logo) mount the background video. The
|
||||
// text is only on screen ~6s, so verify the phases via durable signals:
|
||||
// the video's live health here, and the audio log (speech/song) after.
|
||||
await page.waitForFunction(() => !!document.querySelector('video'), { timeout: 30_000 })
|
||||
// Wait for actual smooth playback (cold-cache buffering right after mount
|
||||
// is masked by the design's 0.3-opacity fade — smoothness is what matters).
|
||||
await page.waitForFunction(() => {
|
||||
const v = document.querySelector('video')
|
||||
return !!v && v.readyState >= 3 && v.currentTime > 0.3
|
||||
}, { timeout: 20_000 })
|
||||
const s1 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')!
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
await page.waitForTimeout(2_000)
|
||||
const s2 = await page.evaluate(() => {
|
||||
const v = document.querySelector('video')
|
||||
if (!v) return null
|
||||
const q = v.getVideoPlaybackQuality?.()
|
||||
return { t: v.currentTime, dropped: q?.droppedVideoFrames ?? 0, total: q?.totalVideoFrames ?? 0 }
|
||||
})
|
||||
expect(s2).not.toBeNull()
|
||||
// The 8.1s video loops; a loop wrap makes (t - t1) negative — treat as full progress.
|
||||
const progressed = s2!.t >= s1.t ? s2!.t - s1.t : s2!.t + (8.1 - s1.t)
|
||||
expect(progressed).toBeGreaterThan(1.2) // ≥1.2s progress in 2s wall = playing smoothly
|
||||
// Steady-state frame drops over the sample window (startup catch-up excluded).
|
||||
const dTotal = s2!.total - s1.total
|
||||
const dDropped = s2!.dropped - s1.dropped
|
||||
if (dTotal > 30) expect(dDropped / dTotal).toBeLessThan(0.2)
|
||||
|
||||
// Splash completes → demo routes to the onboarding intro
|
||||
await page.waitForURL('**/onboarding/intro', { timeout: 60_000 })
|
||||
}
|
||||
|
||||
async function enterDemoAndLogin(page: Page) {
|
||||
// The CTA unmounts mid-click when the router transitions away — dispatch
|
||||
// once, swallow the detach retry, and trust the URL change instead.
|
||||
const cta = page.getByRole('button', { name: /Enter the demo/ })
|
||||
await cta.waitFor({ timeout: 15_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/login', { timeout: 15_000 }),
|
||||
cta.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Demo prefills the password; the finale flag must be armed at this point.
|
||||
expect(await page.evaluate(() => sessionStorage.getItem('archy_onboarding_finale'))).toBe('1')
|
||||
const loginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await loginBtn.waitFor({ timeout: 10_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
loginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
}
|
||||
|
||||
test.describe('first-visit cinematic', () => {
|
||||
test('full no-skip run: sounds, video health, zoom reveal, welcome typing', async ({ page }) => {
|
||||
test.setTimeout(240_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: false })
|
||||
|
||||
// Cinematic audio fired: intro typing loop, the Welcome Noderunner speech
|
||||
// and the synthwave bed (cosmic-updrift) — the exact regression reported.
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// FULL first-entry reveal: big background zoom + glass assembly classes
|
||||
// (recorded by the init-script observer the instant they appear — the
|
||||
// classes only live ~8s and sequential polling can miss the window).
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen), { timeout: 15_000 })
|
||||
.toMatchObject({ zoom: true, glass: true })
|
||||
|
||||
// Welcome typing kicks in ~4s into the reveal and animates the home cards.
|
||||
await expect(page.locator('.home-card-animate').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// The reveal runs 8s, then the zoom layer class clears.
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0, { timeout: 20_000 })
|
||||
|
||||
// The dashboard oomph is WebAudio oscillators — at least the login pop +
|
||||
// oomph layers must have started after the splash's own sounds.
|
||||
const oscCount = await page.evaluate(() => window.__audioLog.filter(e => e === 'osc-start').length)
|
||||
expect(oscCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('skip-intro run still gets speech, song and the dashboard reveal', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
|
||||
await runSplash(page, { skip: true })
|
||||
|
||||
const log = await page.evaluate(() => window.__audioLog.join('|'))
|
||||
expect(log).toContain('welcome-noderunner.mp3')
|
||||
expect(log).toContain('cosmic-updrift.mp3')
|
||||
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('second login is deliberately low-key (no zoom reveal)', async ({ page }) => {
|
||||
test.setTimeout(180_000)
|
||||
await instrumentAudioAndVideo(page)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.__revealSeen.zoom), { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
|
||||
// End the session the way logout does (auth token only — the intro/login
|
||||
// flags survive) and revisit login directly. An authenticated /login visit
|
||||
// would just bounce back to the dashboard via the router guard.
|
||||
await page.evaluate(() => localStorage.removeItem('neode-auth'))
|
||||
// Direct /login navigation (no splash — not a root boot) and re-login.
|
||||
await page.goto(BASE + '/login')
|
||||
await page.locator('#login-password').waitFor({ timeout: 15_000 })
|
||||
// First login happened → static rotated background, not the video.
|
||||
await expect(page.locator('.bg-login-static')).toBeVisible({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('neode_first_login_done'))).toBe('1')
|
||||
|
||||
const reloginBtn = page.getByRole('button', { name: /log ?in/i })
|
||||
await Promise.all([
|
||||
page.waitForURL('**/dashboard**', { timeout: 25_000 }),
|
||||
reloginBtn.click({ noWaitAfter: true }).catch(() => {}),
|
||||
])
|
||||
// Low-key entrance: NO zoom reveal on a regular re-login. The observer
|
||||
// reset with the /login reload, so it would have caught even a transient
|
||||
// reveal since then.
|
||||
await page.waitForTimeout(1500)
|
||||
expect(await page.evaluate(() => window.__revealSeen.zoom ?? false)).toBe(false)
|
||||
await expect(page.locator('.zoom-reveal-bg')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('demo replays the cinematic on a fresh boot at root', async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await freshVisit(page)
|
||||
await runSplash(page, { skip: true })
|
||||
await enterDemoAndLogin(page)
|
||||
|
||||
// Reload at root = a fresh boot → the splash must return even though
|
||||
// neode_intro_seen is now set.
|
||||
await page.goto(BASE + '/')
|
||||
await expect(page.getByText('Enter to Exit')).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
test('video is warmed before the splash needs it', async ({ page }) => {
|
||||
await freshVisit(page)
|
||||
// The warm-up is a detached <video preload=auto> kept on window (Chromium
|
||||
// has no <link rel=preload as=video>). Give it a moment to buffer.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const w = (window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm
|
||||
return w ? { src: w.src, readyState: w.readyState } : null
|
||||
}), { timeout: 15_000 })
|
||||
.toMatchObject({ src: expect.stringContaining('video-intro.mp4') })
|
||||
const ready = await page.evaluate(() =>
|
||||
(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm?.readyState ?? 0)
|
||||
expect(ready).toBeGreaterThanOrEqual(1) // metadata in = download underway
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
// keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B.
|
||||
//
|
||||
// Standalone, re-runnable Playwright spec that logs into the deployed
|
||||
// a test node build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
|
||||
// performs a visit -> away (to the neutral /dashboard/settings tab) ->
|
||||
// return round trip, reporting whether the component instance survived.
|
||||
//
|
||||
// Deliberately does NOT edit measure.ts / surfaces.ts / surface-perf.spec.ts
|
||||
// — the 02-01 harness stays frozen so 02-10 can re-run it unmodified. This
|
||||
// spec reuses SURFACES' navSteps/contentSelector/rootSelector (read-only
|
||||
// import) as the click recipe to reach each tab, but implements its own
|
||||
// corrected stamp/read method plus three instruments the ad-hoc 02-08 probe
|
||||
// did not have:
|
||||
//
|
||||
// 1. A monotonic per-mount signal written by the page itself, independent
|
||||
// of the DOM-element dataset stamp: Vue unconditionally attaches
|
||||
// `el.__vueParentComponent` to every mounted root element (confirmed
|
||||
// by reading node_modules/@vue/runtime-core's `mountElement`, not
|
||||
// gated behind a dev-only flag), whose `.uid` is a per-instance
|
||||
// monotonic counter. Reading this before/after the round trip gives an
|
||||
// INSTANCE-identity signal that can disagree with the dataset-mark's
|
||||
// ELEMENT-identity signal if the probe's selector picks a different
|
||||
// cached instance than the one actually under test (suspect 1).
|
||||
// 2. `page.on('pageerror')` / `page.on('console')` captured for the whole
|
||||
// round trip and printed — a Server-specific runtime error during
|
||||
// activation/deactivation (suspect 2) would surface here even though
|
||||
// it might not throw synchronously enough to fail the Playwright step
|
||||
// itself.
|
||||
// 3. After each hop: `document.querySelectorAll('.view-container').length`
|
||||
// and `location.pathname`, so cache population and the actual route
|
||||
// path are visible in the transcript (suspects 3 and 4).
|
||||
//
|
||||
// Corrected remount method (documented in 02-FINDINGS.md's ## Results
|
||||
// preamble): stamp/read the `.view-container`-class ANCESTOR (or the
|
||||
// surface's own `rootSelector`, for the two surfaces — Mesh, Chat — that
|
||||
// don't use `.view-container`) of the surface's own VISIBLE contentSelector
|
||||
// match, using `getBoundingClientRect`/`offsetParent` to exclude KeepAlive's
|
||||
// inactive cached instances — never `document.querySelector`'s first DOM
|
||||
// match, which can silently pick a different cached instance once multiple
|
||||
// `.view-container`s coexist in the document at once.
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
// Re-declared rather than imported from '@/views/dashboard/keepAliveRoutes':
|
||||
// the e2e package's tsconfig (see tsconfig.app.json's `include`) does not
|
||||
// cover `e2e/**`, so the `@/*` path alias used across neode-ui/src is not
|
||||
// guaranteed to resolve under Playwright's own TS transform. surfaces.ts and
|
||||
// measure.ts already avoid the alias for the same reason (no `@/` import in
|
||||
// either file) — this list is the exact literal from keepAliveRoutes.ts
|
||||
// (TAB_ORDER minus withheld `/dashboard/settings`, plus `/dashboard/discover`).
|
||||
const KEEP_ALIVE_PATHS = new Set<string>([
|
||||
'/dashboard',
|
||||
'/dashboard/apps',
|
||||
'/dashboard/marketplace',
|
||||
'/dashboard/cloud',
|
||||
'/dashboard/mesh',
|
||||
'/dashboard/server',
|
||||
'/dashboard/web5',
|
||||
'/dashboard/fleet',
|
||||
'/dashboard/chat',
|
||||
'/dashboard/discover',
|
||||
])
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
// Mirrors surface-perf.spec.ts's login() (itself mirroring app-launch.spec.ts)
|
||||
// verbatim — do not invent a second auth path.
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
interface HopSnapshot {
|
||||
viewContainerCount: number
|
||||
pathname: string
|
||||
}
|
||||
|
||||
async function snapshotHop(page: Page): Promise<HopSnapshot> {
|
||||
return page.evaluate(() => ({
|
||||
viewContainerCount: document.querySelectorAll('.view-container').length,
|
||||
pathname: location.pathname,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the DOM has stopped churning: the raw element count for
|
||||
* `contentSelector` (NOT filtered by visibility — some contentSelectors,
|
||||
* e.g. `.home-card`, legitimately match several sibling cards at once, so
|
||||
* "exactly 1" is the wrong invariant) reads identically across 5 consecutive
|
||||
* 100ms-spaced polls.
|
||||
*
|
||||
* Discovered mid-investigation (not hypothesized up front): `/dashboard/settings`
|
||||
* — the away tab EVERY round trip in this probe (and in measure.ts's own
|
||||
* `NEUTRAL_SELECTOR` convention) uses — renders `AccountInfoSection.vue` and
|
||||
* `KioskDisplaySection.vue` unconditionally (Settings.vue has no tabs/
|
||||
* accordion gating), and BOTH carry `data-controller-container`. Settings'
|
||||
* own root ALSO gets the `view-container` fallthrough class (every non-full-
|
||||
* bleed route does). That means Settings' OWN content matches the exact
|
||||
* generic `.view-container [data-controller-container]` selector Server,
|
||||
* Web5 and Fleet all use — and Settings' leave-transition keeps its DOM
|
||||
* genuinely present (in the document, still counted by querySelectorAll)
|
||||
* for the transition's full duration while the RETURN target's enter-
|
||||
* transition is already progressing. A stamp/read taken during that overlap
|
||||
* window can silently pick the still-present-but-leaving Settings element
|
||||
* instead of the actual target — a false "remounted" verdict that has
|
||||
* nothing to do with KeepAlive at all. Waiting for the raw count to settle
|
||||
* lets the leave-transition finish (Vue's <Transition> removes the leaving
|
||||
* element from the DOM once its leave hook completes) before this probe
|
||||
* stamps or reads anything.
|
||||
*/
|
||||
async function waitForDomSettled(page: Page, contentSelector: string, timeoutMs: number): Promise<void> {
|
||||
await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
w.__probeStableCount = 0
|
||||
w.__probeLastLen = document.querySelectorAll(sel).length
|
||||
}, contentSelector)
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const w = window as unknown as { __probeStableCount?: number; __probeLastLen?: number }
|
||||
const len = document.querySelectorAll(sel).length
|
||||
if (w.__probeLastLen === len) {
|
||||
w.__probeStableCount = (w.__probeStableCount ?? 0) + 1
|
||||
} else {
|
||||
w.__probeStableCount = 0
|
||||
}
|
||||
w.__probeLastLen = len
|
||||
return (w.__probeStableCount ?? 0) >= 5
|
||||
},
|
||||
contentSelector,
|
||||
{ timeout: timeoutMs, polling: 100 }
|
||||
)
|
||||
}
|
||||
|
||||
interface ProbeReading {
|
||||
ok: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
}
|
||||
|
||||
/** Locate the VISIBLE contentSelector match's rootSelector ancestor (or
|
||||
* self, if contentSelector === rootSelector) and stamp it with `mark`,
|
||||
* recording the Vue instance uid/type-name found on it at the same moment. */
|
||||
async function stampVisibleRoot(page: Page, contentSelector: string, rootSelector: string, mark: string): Promise<ProbeReading> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, mark }) => {
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, uid: null, typeName: null }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, uid: null, typeName: null }
|
||||
root.dataset.perfProbeMark = mark
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, uid, typeName }
|
||||
},
|
||||
{ contentSelector, rootSelector, mark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RootMarkDebug {
|
||||
mark: string | null
|
||||
visible: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
interface ReadResult {
|
||||
ok: boolean
|
||||
markMatches: boolean
|
||||
uid: number | null
|
||||
typeName: string | null
|
||||
allRootMarks: RootMarkDebug[]
|
||||
/** The authoritative "what does the user actually see" signal:
|
||||
* `document.elementFromPoint()` at the viewport center performs real hit
|
||||
* testing (respects stacking/z-index/opacity), unlike the
|
||||
* `offsetParent`/`getBoundingClientRect` heuristic used above, which
|
||||
* cannot distinguish the true foreground root from another root that
|
||||
* merely has non-zero layout dimensions while stacked behind it. */
|
||||
elementFromPointMark: string | null
|
||||
}
|
||||
|
||||
async function readVisibleRoot(
|
||||
page: Page,
|
||||
contentSelector: string,
|
||||
rootSelector: string,
|
||||
expectedMark: string
|
||||
): Promise<ReadResult> {
|
||||
return page.evaluate(
|
||||
({ contentSelector, rootSelector, expectedMark }) => {
|
||||
// Diagnostic: every element matching rootSelector ANYWHERE in the
|
||||
// document (not just the one reachable from the visible content
|
||||
// match), reporting its own mark + visibility. If the ORIGINAL
|
||||
// stamped root still carries its mark but is not the one this read
|
||||
// considers "visible", that is a completely different finding
|
||||
// (a still-alive-but-orphaned cached instance) than the mark being
|
||||
// gone from every root entirely (a genuine destroy+recreate).
|
||||
const allRoots = Array.from(document.querySelectorAll(rootSelector)) as HTMLElement[]
|
||||
const allRootMarks = allRoots.map((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return {
|
||||
mark: el.dataset.perfProbeMark ?? null,
|
||||
visible: el.offsetParent !== null && (rect.width > 0 || rect.height > 0),
|
||||
connected: el.isConnected,
|
||||
}
|
||||
})
|
||||
|
||||
// Authoritative real-hit-test signal, independent of the
|
||||
// offsetParent/rect heuristic above.
|
||||
const cx = Math.floor(window.innerWidth / 2)
|
||||
const cy = Math.floor(window.innerHeight / 2)
|
||||
const topEl = document.elementFromPoint(cx, cy) as HTMLElement | null
|
||||
const topRoot = (topEl?.closest(rootSelector) as HTMLElement | null) ?? null
|
||||
const elementFromPointMark = topRoot?.dataset.perfProbeMark ?? null
|
||||
|
||||
const candidates = Array.from(document.querySelectorAll(contentSelector)) as HTMLElement[]
|
||||
const visible = candidates.find((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return el.offsetParent !== null && (rect.width > 0 || rect.height > 0)
|
||||
})
|
||||
if (!visible) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
const root = (visible.closest(rootSelector) as HTMLElement | null) ?? (visible.matches(rootSelector) ? visible : null)
|
||||
if (!root) return { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks, elementFromPointMark }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const comp = (root as any).__vueParentComponent ?? null
|
||||
const uid: number | null = comp ? (comp.uid ?? null) : null
|
||||
const typeName: string | null = comp ? (comp.type?.__name ?? comp.type?.name ?? null) : null
|
||||
return { ok: true, markMatches: root.dataset.perfProbeMark === expectedMark, uid, typeName, allRootMarks, elementFromPointMark }
|
||||
},
|
||||
{ contentSelector, rootSelector, expectedMark }
|
||||
)
|
||||
}
|
||||
|
||||
interface RoundTripResult {
|
||||
path: string
|
||||
label: string
|
||||
/** Primary verdict: did the SAME element survive the round trip (the
|
||||
* corrected 02-08 method's own signal)? null = could not be probed. */
|
||||
elementSurvived: boolean | null
|
||||
/** Independent instance-identity signal (instrument 1): did the SAME Vue
|
||||
* component instance (by internal uid) survive? null = could not be read
|
||||
* (e.g. __vueParentComponent absent, or the view was never found). */
|
||||
instanceSurvived: boolean | null
|
||||
instanceTypeNameBeforeAway: string | null
|
||||
instanceTypeNameAfterReturn: string | null
|
||||
/** Diagnostic: every rootSelector-matching element in the document at
|
||||
* read-back time, with its own mark + visibility/connected state — shows
|
||||
* whether an unmatched original root is genuinely gone vs still present
|
||||
* (just not the one the visibility filter picked). */
|
||||
allRootMarksAtRead: RootMarkDebug[]
|
||||
/** Authoritative real-hit-test signal at read-back time (see ReadResult's
|
||||
* own doc comment) — null means either the read failed or elementFromPoint
|
||||
* found no rootSelector ancestor at the viewport center. */
|
||||
elementFromPointMarkAtRead: string | null
|
||||
elementFromPointSurvived: boolean | null
|
||||
afterVisit: HopSnapshot | null
|
||||
afterAway: HopSnapshot | null
|
||||
afterReturn: HopSnapshot | null
|
||||
consoleMessages: string[]
|
||||
pageErrors: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort dismissal of a stray full-screen overlay before it blocks the
|
||||
* next click — mirrors measure.ts's own `dismissOverlays()` (this spec
|
||||
* deliberately does not import from measure.ts, so the logic is duplicated
|
||||
* here rather than shared, per the "don't edit the frozen 02-01 harness"
|
||||
* constraint). a test node currently runs at 85% disk (02-FINDINGS.md
|
||||
* Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast
|
||||
* live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper
|
||||
* has no `pointer-events: none`, so it silently intercepts clicks on
|
||||
* whatever sits behind it — an environmental condition unrelated to the
|
||||
* KeepAlive remount question this probe exists to answer, and one this
|
||||
* probe must route around rather than be blocked by.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
// no-op — best-effort only
|
||||
}
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) {
|
||||
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 3
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function goHome(page: Page): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[]): Promise<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, NAV_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
async function roundTrip(page: Page, surface: Surface): Promise<RoundTripResult> {
|
||||
const consoleMessages: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
const onConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
consoleMessages.push(`[${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err.message)
|
||||
}
|
||||
page.on('console', onConsole)
|
||||
page.on('pageerror', onPageError)
|
||||
|
||||
let afterVisit: HopSnapshot | null = null
|
||||
let afterAway: HopSnapshot | null = null
|
||||
let afterReturn: HopSnapshot | null = null
|
||||
let stampReading: ProbeReading = { ok: false, uid: null, typeName: null }
|
||||
let readReading: ReadResult = { ok: false, markMatches: false, uid: null, typeName: null, allRootMarks: [], elementFromPointMark: null }
|
||||
|
||||
try {
|
||||
await goHome(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
// Wait for the URL first, THEN the content selector: three of these ten
|
||||
// surfaces (Server, Web5, Fleet) share the generic
|
||||
// `.view-container [data-controller-container]` contentSelector
|
||||
// (02-FINDINGS.md's own documented ambiguity), so waiting on the
|
||||
// selector alone can resolve instantly against the PREVIOUS tab's still-
|
||||
// visible content before the navigation actually lands — a probe
|
||||
// artifact this instrumentation (pathname logging, instrument 3) caught
|
||||
// directly rather than one I could have caught by inspection alone.
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterVisit = await snapshotHop(page)
|
||||
|
||||
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
stampReading = await stampVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
if (surface.closeSelector) {
|
||||
// Deliberately NOT routed through clickWithGuard: dismissOverlays()
|
||||
// treats any "Close"-labelled dialog button as a stray overlay to
|
||||
// dismiss, which is exactly this element for a modal-trigger surface
|
||||
// — same reasoning as measure.ts's own runOnce().
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
afterAway = await snapshotHop(page)
|
||||
|
||||
await clickChain(page, surface.navSteps)
|
||||
if (!surface.path.includes(':')) {
|
||||
await page.waitForURL((url) => url.pathname === surface.path, { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
await waitForDomSettled(page, surface.contentSelector, CONTENT_TIMEOUT)
|
||||
afterReturn = await snapshotHop(page)
|
||||
|
||||
readReading = await readVisibleRoot(page, surface.contentSelector, surface.rootSelector, mark)
|
||||
|
||||
const elementSurvived = stampReading.ok && readReading.ok ? readReading.markMatches : null
|
||||
const instanceSurvived =
|
||||
stampReading.ok && readReading.ok && stampReading.uid != null && readReading.uid != null
|
||||
? stampReading.uid === readReading.uid
|
||||
: null
|
||||
const elementFromPointSurvived = readReading.elementFromPointMark != null ? readReading.elementFromPointMark === mark : null
|
||||
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived,
|
||||
instanceSurvived,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: null,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
path: surface.path,
|
||||
label: surface.label,
|
||||
elementSurvived: null,
|
||||
instanceSurvived: null,
|
||||
instanceTypeNameBeforeAway: stampReading.typeName,
|
||||
instanceTypeNameAfterReturn: readReading.typeName,
|
||||
allRootMarksAtRead: readReading.allRootMarks,
|
||||
elementFromPointMarkAtRead: readReading.elementFromPointMark,
|
||||
elementFromPointSurvived: null,
|
||||
afterVisit,
|
||||
afterAway,
|
||||
afterReturn,
|
||||
consoleMessages,
|
||||
pageErrors,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
} finally {
|
||||
page.off('console', onConsole)
|
||||
page.off('pageerror', onPageError)
|
||||
}
|
||||
}
|
||||
|
||||
test('keepalive-remount-probe: every KEEP_ALIVE_PATHS surface survives a tab round-trip (or reports why it could not be probed)', async ({ page }) => {
|
||||
test.setTimeout(10 * 60 * 1000)
|
||||
|
||||
await login(page)
|
||||
|
||||
// Session-wide capture, in addition to roundTrip()'s own per-surface
|
||||
// listeners: a delayed/async error (e.g. a promise that settles after a
|
||||
// round trip's own listeners are already detached, mid-flight when we've
|
||||
// moved on to the next surface) would otherwise be silently missed and
|
||||
// wrongly attributed to "no error" for the surface actually responsible.
|
||||
const sessionLog: string[] = []
|
||||
const sessionStart = Date.now()
|
||||
const onSessionConsole = (msg: { type: () => string; text: () => string }) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [console:${msg.type()}] ${msg.text()}`)
|
||||
}
|
||||
const onSessionPageError = (err: Error) => {
|
||||
sessionLog.push(`[+${Date.now() - sessionStart}ms] [pageerror] ${err.message}`)
|
||||
}
|
||||
page.on('console', onSessionConsole)
|
||||
page.on('pageerror', onSessionPageError)
|
||||
|
||||
// De-duplicate by path, keeping the FIRST matching SURFACES row: two rows
|
||||
// share `path: '/dashboard'` (the `home` main-tab row and the `wallet-send`
|
||||
// modal-trigger row, which records Home's own path only for reference,
|
||||
// per surfaces.ts's own doc comment on `closeSelector`) — the modal is not
|
||||
// itself a KEEP_ALIVE_PATHS-registered route, so only `home` should count.
|
||||
const seenPaths = new Set<string>()
|
||||
const probeSurfaces = SURFACES.filter((s) => {
|
||||
if (!KEEP_ALIVE_PATHS.has(s.path) || seenPaths.has(s.path)) return false
|
||||
seenPaths.add(s.path)
|
||||
return true
|
||||
})
|
||||
expect(probeSurfaces.length).toBe(KEEP_ALIVE_PATHS.size)
|
||||
|
||||
const results: RoundTripResult[] = []
|
||||
for (const surface of probeSurfaces) {
|
||||
const result = await roundTrip(page, surface)
|
||||
results.push(result)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[keepalive-remount-probe] ${result.path} (${result.label}): ` +
|
||||
`elementSurvived=${result.elementSurvived} instanceSurvived=${result.instanceSurvived} ` +
|
||||
`typeName(before/after)=${result.instanceTypeNameBeforeAway}/${result.instanceTypeNameAfterReturn} ` +
|
||||
`viewContainerCount(visit/away/return)=${result.afterVisit?.viewContainerCount ?? 'n/a'}/${result.afterAway?.viewContainerCount ?? 'n/a'}/${result.afterReturn?.viewContainerCount ?? 'n/a'} ` +
|
||||
`pathname(visit/return)=${result.afterVisit?.pathname ?? 'n/a'}/${result.afterReturn?.pathname ?? 'n/a'} ` +
|
||||
`elementFromPointSurvived=${result.elementFromPointSurvived} ` +
|
||||
`pageErrors=${result.pageErrors.length} error=${result.error ?? 'none'}`
|
||||
)
|
||||
if (result.pageErrors.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] pageErrors: ${JSON.stringify(result.pageErrors)}`)
|
||||
}
|
||||
if (result.elementSurvived === false) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] allRootMarksAtRead (${result.path}): ${JSON.stringify(result.allRootMarksAtRead)}`)
|
||||
}
|
||||
}
|
||||
|
||||
page.off('console', onSessionConsole)
|
||||
page.off('pageerror', onSessionPageError)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full session log (${sessionLog.length} entries):\n${sessionLog.join('\n')}`)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[keepalive-remount-probe] full results JSON:\n${JSON.stringify(results, null, 2)}`)
|
||||
|
||||
// Structural assertion only — every registered path must have been
|
||||
// attempted and produce a result row, mirroring surface-perf.spec.ts's own
|
||||
// sole assertion (`expect(results.length).toBe(SURFACES.length)`). Whether
|
||||
// each one SURVIVED, or could even be probed at all, is the finding this
|
||||
// probe exists to produce, not a pass/fail gate on the spec itself: Mesh's
|
||||
// device-not-reporting-connected condition and Chat's AIUI-connection
|
||||
// timing are both pre-existing, environment-dependent blockers
|
||||
// 02-FINDINGS.md's own `## Results` section already documents as
|
||||
// "unmeasured" rather than "failed" — an errored sample is recorded, never
|
||||
// discarded and never used to fail the harness itself, exactly like
|
||||
// `measure.ts`'s `measureSurface()` treats its own per-run errors.
|
||||
expect(results.length).toBe(probeSurfaces.length)
|
||||
})
|
||||
@@ -0,0 +1,387 @@
|
||||
// measureSurface() — first-visit vs revisit timing, RPC request trace, and a
|
||||
// remount probe for one SURFACES row (02-01-PLAN.md Task 1).
|
||||
//
|
||||
// Design notes (see surfaces.ts header for the navigation rationale):
|
||||
// - Navigation between surfaces always happens via real UI clicks
|
||||
// (RouterLink/button), never `page.goto()`, so a revisit exercises actual
|
||||
// Vue Router client-side transitions rather than a full page reload.
|
||||
// - The remount probe stamps `rootSelector`'s DOM node with a unique value
|
||||
// right after first-visit paints, then reads it back after the away/back
|
||||
// round-trip. A surviving value means the component instance was reused
|
||||
// (no remount); a missing/changed value means it was destroyed and
|
||||
// recreated.
|
||||
// - RPC calls are traced via `page.on('request')`/`requestfinished`,
|
||||
// filtered to the `/rpc/v1` endpoint the rpc-client posts to. Only method
|
||||
// name + timing are recorded — no request/response bodies (T-02-06).
|
||||
// - `maxConcurrentRpc` / `rpcWallClockMs` are derived from the recorded
|
||||
// calls' [start, start+duration] intervals via a sweep-line, so a serial
|
||||
// waterfall (maxConcurrentRpc === 1, 2+ calls) is distinguishable from an
|
||||
// already-parallel fan-out without re-reading application source.
|
||||
|
||||
import type { Page, Request } from '@playwright/test'
|
||||
import type { Surface } from './surfaces'
|
||||
|
||||
const RPC_PATH = '/rpc/v1'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const DEFAULT_NAV_TIMEOUT = 20_000
|
||||
const DEFAULT_CONTENT_TIMEOUT = 20_000
|
||||
|
||||
export interface RpcCall {
|
||||
method: string
|
||||
/** Wall-clock offset (ms) from the start of the measured phase. */
|
||||
startedAtMs: number
|
||||
/** Request duration in ms (null if it never finished/failed to resolve timing). */
|
||||
durationMs: number | null
|
||||
}
|
||||
|
||||
export interface SurfaceSample {
|
||||
firstVisitMs: number | null
|
||||
revisitMs: number | null
|
||||
firstVisitRpcCount: number | null
|
||||
revisitRpcCount: number | null
|
||||
revisitRpcCalls: RpcCall[]
|
||||
maxConcurrentRpc: number | null
|
||||
rpcWallClockMs: number | null
|
||||
/** true = component instance was reused across the away/back round-trip
|
||||
* (no remount); false = it was destroyed and recreated; null = not probed
|
||||
* (e.g. the run errored before the probe could run). */
|
||||
remounted: boolean | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SurfaceMeasurement {
|
||||
id: string
|
||||
label: string
|
||||
path: string
|
||||
kind: Surface['kind']
|
||||
runs: number
|
||||
samples: SurfaceSample[]
|
||||
/** Median across successful samples (null if every sample errored). */
|
||||
firstVisitMs: number | null
|
||||
revisitMs: number | null
|
||||
firstVisitRpcCount: number | null
|
||||
revisitRpcCount: number | null
|
||||
/** RPC call trace from the sample nearest the median revisitMs (or the
|
||||
* last successful sample if no median could be computed). */
|
||||
revisitRpcCalls: RpcCall[]
|
||||
maxConcurrentRpc: number | null
|
||||
rpcWallClockMs: number | null
|
||||
/** Majority vote across samples that were actually probed. */
|
||||
remounted: boolean | null
|
||||
/** Set only when every sample for this surface errored. */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface MeasureOptions {
|
||||
/** Number of first-visit/revisit round-trips to sample (default 3). */
|
||||
runs?: number
|
||||
navTimeoutMs?: number
|
||||
contentTimeoutMs?: number
|
||||
}
|
||||
|
||||
function median(values: Array<number | null>): number | null {
|
||||
const nums = values.filter((v): v is number => v != null).sort((a, b) => a - b)
|
||||
if (nums.length === 0) return null
|
||||
const mid = Math.floor(nums.length / 2)
|
||||
return nums.length % 2 === 0 ? (nums[mid - 1]! + nums[mid]!) / 2 : nums[mid]!
|
||||
}
|
||||
|
||||
/** Sweep-line over [start, start+duration] intervals — max overlap count and
|
||||
* total wall-clock span from first start to last end. */
|
||||
function deriveConcurrency(calls: RpcCall[]): { maxConcurrentRpc: number | null; rpcWallClockMs: number | null } {
|
||||
const timed = calls.filter((c) => c.durationMs != null)
|
||||
if (timed.length === 0) return { maxConcurrentRpc: calls.length > 0 ? null : 0, rpcWallClockMs: calls.length > 0 ? null : 0 }
|
||||
type Edge = { at: number; delta: number }
|
||||
const edges: Edge[] = []
|
||||
let minStart = Infinity
|
||||
let maxEnd = -Infinity
|
||||
for (const c of timed) {
|
||||
const end = c.startedAtMs + (c.durationMs ?? 0)
|
||||
edges.push({ at: c.startedAtMs, delta: 1 }, { at: end, delta: -1 })
|
||||
minStart = Math.min(minStart, c.startedAtMs)
|
||||
maxEnd = Math.max(maxEnd, end)
|
||||
}
|
||||
edges.sort((a, b) => a.at - b.at)
|
||||
let running = 0
|
||||
let max = 0
|
||||
for (const e of edges) {
|
||||
running += e.delta
|
||||
if (running > max) max = running
|
||||
}
|
||||
return { maxConcurrentRpc: max, rpcWallClockMs: maxEnd - minStart }
|
||||
}
|
||||
|
||||
interface RpcTracker {
|
||||
calls: RpcCall[]
|
||||
detach: () => void
|
||||
}
|
||||
|
||||
function attachRpcTracker(page: Page, phaseStart: number): RpcTracker {
|
||||
const calls: RpcCall[] = []
|
||||
const pending = new Map<Request, { method: string; start: number }>()
|
||||
|
||||
const isRpcRequest = (req: Request) => req.method() === 'POST' && req.url().includes(RPC_PATH)
|
||||
|
||||
const onRequest = (req: Request) => {
|
||||
if (!isRpcRequest(req)) return
|
||||
let method = 'unknown'
|
||||
try {
|
||||
const body = req.postData()
|
||||
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
|
||||
} catch {
|
||||
// Malformed/unreadable body — keep 'unknown', never persist the body itself.
|
||||
}
|
||||
pending.set(req, { method, start: Date.now() - phaseStart })
|
||||
}
|
||||
|
||||
const onSettled = (req: Request) => {
|
||||
const info = pending.get(req)
|
||||
if (!info) return
|
||||
pending.delete(req)
|
||||
calls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - phaseStart - info.start })
|
||||
}
|
||||
|
||||
page.on('request', onRequest)
|
||||
page.on('requestfinished', onSettled)
|
||||
page.on('requestfailed', onSettled)
|
||||
|
||||
return {
|
||||
calls,
|
||||
detach: () => {
|
||||
page.off('request', onRequest)
|
||||
page.off('requestfinished', onSettled)
|
||||
page.off('requestfailed', onSettled)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click with a dismiss-and-retry guard: a stray overlay (e.g. the Companion
|
||||
* app's once-per-browser auto-show intro) can appear mid-attempt, after
|
||||
* `dismissOverlays()` already ran but before Playwright's own actionability
|
||||
* wait resolves. Splitting the timeout budget across a few short attempts,
|
||||
* re-running `dismissOverlays()` between each, clears it reliably instead of
|
||||
* burning the whole budget on a single blocked attempt.
|
||||
*/
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 3
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
|
||||
for (const selector of steps) {
|
||||
await clickWithGuard(page, selector, timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
async function stampRoot(page: Page, rootSelector: string, mark: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
({ sel, mark }) => {
|
||||
const el = document.querySelector(sel) as HTMLElement | null
|
||||
if (!el) return false
|
||||
el.dataset.perfProbe = mark
|
||||
return true
|
||||
},
|
||||
{ sel: rootSelector, mark }
|
||||
)
|
||||
}
|
||||
|
||||
async function readRootProbe(page: Page, rootSelector: string): Promise<string | null> {
|
||||
return page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel) as HTMLElement | null
|
||||
return el?.dataset.perfProbe ?? null
|
||||
}, rootSelector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort dismissal of a stray full-screen overlay (a filter/pairing/
|
||||
* update modal opened by the surface under test, or left over from a prior
|
||||
* one) before it blocks the next click. One surface's leftover UI must never
|
||||
* cascade a click-intercept failure into every remaining surface in the run.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
// no-op — best-effort only
|
||||
}
|
||||
// Match any "Close"-flavoured aria-label (e.g. "Close companion modal"),
|
||||
// not just an exact "Close" — modal close buttons across the app phrase
|
||||
// this inconsistently.
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) {
|
||||
await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function goHome(page: Page, timeoutMs: number): Promise<void> {
|
||||
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — if a prior surface's
|
||||
// run ended sitting on /dashboard/chat (e.g. chat's own revisit re-opened
|
||||
// it as its last step, or a prior run errored out mid-chat), the sidebar is
|
||||
// hidden and unclickable. Back out of chat first so the sidebar reappears
|
||||
// before relying on it below.
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
const closed = await page
|
||||
.locator('.chat-close-btn')
|
||||
.first()
|
||||
.click({ timeout: 5_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
// On real AIUI hardware the close pill can sit behind AIUI's own
|
||||
// "connecting" overlay for longer than any reasonable click budget — if
|
||||
// the close button itself is unreachable, this is a recovery path, not a
|
||||
// measurement, so a hard reload to break out is preferable to leaving
|
||||
// every remaining surface stuck behind a permanently hidden sidebar.
|
||||
if (!closed) {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
}
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
async function runOnce(page: Page, surface: Surface, opts: Required<Pick<MeasureOptions, 'navTimeoutMs' | 'contentTimeoutMs'>>): Promise<SurfaceSample> {
|
||||
const { navTimeoutMs, contentTimeoutMs } = opts
|
||||
|
||||
// Start every run from a known, stable point (dashboard home) so
|
||||
// firstVisitMs measures "navigate from the dashboard home to the surface"
|
||||
// exactly as specified, regardless of what the previous surface left us on.
|
||||
await goHome(page, navTimeoutMs)
|
||||
|
||||
const t0 = Date.now()
|
||||
const firstTracker = attachRpcTracker(page, t0)
|
||||
await clickChain(page, surface.navSteps, navTimeoutMs)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
|
||||
const firstVisitMs = Date.now() - t0
|
||||
firstTracker.detach()
|
||||
const firstVisitRpcCount = firstTracker.calls.length
|
||||
|
||||
const mark = `probe-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const stamped = await stampRoot(page, surface.rootSelector, mark)
|
||||
|
||||
// Away step: for an in-page trigger (modal), close it; otherwise navigate
|
||||
// to the neutral Settings tab via the sidebar (never back to this surface's
|
||||
// own navSteps, so the round-trip is genuine).
|
||||
//
|
||||
// Deliberately NOT routed through clickWithGuard here: dismissOverlays()
|
||||
// treats any "Close"-labelled button inside a dialog as a stray overlay to
|
||||
// dismiss — which is exactly this element when closeSelector targets the
|
||||
// surface's own dialog/panel, causing it to close a beat before this click
|
||||
// runs and leaving the click with nothing to find.
|
||||
if (surface.closeSelector) {
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: navTimeoutMs })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, navTimeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: navTimeoutMs })
|
||||
}
|
||||
|
||||
const t1 = Date.now()
|
||||
const revisitTracker = attachRpcTracker(page, t1)
|
||||
await clickChain(page, surface.navSteps, navTimeoutMs)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: contentTimeoutMs })
|
||||
const revisitMs = Date.now() - t1
|
||||
revisitTracker.detach()
|
||||
const revisitRpcCalls = revisitTracker.calls
|
||||
const revisitRpcCount = revisitRpcCalls.length
|
||||
|
||||
const remounted = stamped ? (await readRootProbe(page, surface.rootSelector)) !== mark : null
|
||||
|
||||
const { maxConcurrentRpc, rpcWallClockMs } = deriveConcurrency(revisitRpcCalls)
|
||||
|
||||
return {
|
||||
firstVisitMs,
|
||||
revisitMs,
|
||||
firstVisitRpcCount,
|
||||
revisitRpcCount,
|
||||
revisitRpcCalls,
|
||||
maxConcurrentRpc,
|
||||
rpcWallClockMs,
|
||||
remounted,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function errorSample(err: unknown): SurfaceSample {
|
||||
return {
|
||||
firstVisitMs: null,
|
||||
revisitMs: null,
|
||||
firstVisitRpcCount: null,
|
||||
revisitRpcCount: null,
|
||||
revisitRpcCalls: [],
|
||||
maxConcurrentRpc: null,
|
||||
rpcWallClockMs: null,
|
||||
remounted: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
|
||||
export async function measureSurface(page: Page, surface: Surface, opts: MeasureOptions = {}): Promise<SurfaceMeasurement> {
|
||||
const runs = opts.runs ?? 3
|
||||
const navTimeoutMs = opts.navTimeoutMs ?? DEFAULT_NAV_TIMEOUT
|
||||
const contentTimeoutMs = opts.contentTimeoutMs ?? DEFAULT_CONTENT_TIMEOUT
|
||||
|
||||
const samples: SurfaceSample[] = []
|
||||
for (let i = 0; i < runs; i++) {
|
||||
try {
|
||||
samples.push(await runOnce(page, surface, { navTimeoutMs, contentTimeoutMs }))
|
||||
} catch (err) {
|
||||
samples.push(errorSample(err))
|
||||
}
|
||||
}
|
||||
|
||||
const successful = samples.filter((s) => s.error == null)
|
||||
const firstVisitMs = median(successful.map((s) => s.firstVisitMs))
|
||||
const revisitMs = median(successful.map((s) => s.revisitMs))
|
||||
const firstVisitRpcCount = median(successful.map((s) => s.firstVisitRpcCount))
|
||||
const revisitRpcCount = median(successful.map((s) => s.revisitRpcCount))
|
||||
|
||||
// Representative sample for the call trace / concurrency fields: the
|
||||
// successful sample whose revisitMs is closest to the computed median
|
||||
// (falls back to the last successful sample when no median exists).
|
||||
let representative: SurfaceSample | null = null
|
||||
if (successful.length > 0) {
|
||||
if (revisitMs != null) {
|
||||
representative = successful.reduce((best, s) =>
|
||||
Math.abs((s.revisitMs ?? Infinity) - revisitMs) < Math.abs((best.revisitMs ?? Infinity) - revisitMs) ? s : best
|
||||
)
|
||||
} else {
|
||||
representative = successful[successful.length - 1]!
|
||||
}
|
||||
}
|
||||
|
||||
const remountedVotes = successful.map((s) => s.remounted).filter((v): v is boolean => v != null)
|
||||
const trueCount = remountedVotes.filter(Boolean).length
|
||||
const remounted = remountedVotes.length === 0 ? null : trueCount >= remountedVotes.length - trueCount
|
||||
|
||||
return {
|
||||
id: surface.id,
|
||||
label: surface.label,
|
||||
path: surface.path,
|
||||
kind: surface.kind,
|
||||
runs,
|
||||
samples,
|
||||
firstVisitMs,
|
||||
revisitMs,
|
||||
firstVisitRpcCount,
|
||||
revisitRpcCount,
|
||||
revisitRpcCalls: representative?.revisitRpcCalls ?? [],
|
||||
maxConcurrentRpc: representative?.maxConcurrentRpc ?? null,
|
||||
rpcWallClockMs: representative?.rpcWallClockMs ?? null,
|
||||
remounted,
|
||||
error: successful.length === 0 ? (samples[samples.length - 1]?.error ?? 'all runs failed') : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// profile-revisit.spec.ts — 02-11 gap-closure diagnostic (D-10: measure before
|
||||
// fix). Standalone, additive script — does NOT edit surfaces.ts / measure.ts
|
||||
// / surface-perf.spec.ts (the frozen 02-01 harness stays frozen).
|
||||
//
|
||||
// 02-VERIFICATION.md's gap 2 confirmed six surfaces regressed on revisit with
|
||||
// flat-or-improved RPC counts — i.e. the cost is client-side render/reactivity,
|
||||
// not network. This script captures a REAL CPU profile (CDP Profiler domain —
|
||||
// the same sampling data Chrome DevTools' Performance panel visualizes as a
|
||||
// flame chart) during exactly the harness's own measured window (click chain
|
||||
// -> contentSelector visible) for each named surface's revisit, then
|
||||
// aggregates self-time by function so the dominant cost can be named with
|
||||
// profiling evidence instead of guessed from source reading alone.
|
||||
//
|
||||
// Usage:
|
||||
// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \
|
||||
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { SURFACES, type Surface } from './surfaces'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const NEUTRAL_SELECTOR = '[data-controller-zone="sidebar"] a[href="/dashboard/settings"]'
|
||||
const NAV_TIMEOUT = 20_000
|
||||
const CONTENT_TIMEOUT = 20_000
|
||||
|
||||
// Every surface 02-VERIFICATION.md named as regressed, plus Fleet (the
|
||||
// out-of-scope bonus 02-10 flagged as the most severe magnitude of the same
|
||||
// mechanism) — all six of this gap plan's must-haves.
|
||||
const TARGET_IDS = ['web5', 'server', 'discover', 'app-details', 'openwrt-gateway', 'fleet']
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.keyboard.press('Escape')
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
const closeButtons = page.locator(
|
||||
'[role="dialog"] button[aria-label*="Close" i], .fixed.inset-0 button[aria-label*="Close" i]'
|
||||
)
|
||||
const count = await closeButtons.count().catch(() => 0)
|
||||
if (count > 0) await closeButtons.first().click({ timeout: 2_000 }).catch(() => {})
|
||||
// HealthNotifications.vue's dismiss button has no aria-label at all (a bare
|
||||
// SVG X icon) — the aria-label selector above never matches it, and per
|
||||
// 02-FINDINGS.md's Outstanding section its `.fixed.right-4.z-[200]` wrapper
|
||||
// has no `pointer-events: none`, so a still-open toast can intercept a
|
||||
// click meant for page content underneath it (e.g. a disk-usage warning
|
||||
// sitting over the "OpenWrt Gateway" link). Dismiss any visible one.
|
||||
const healthToastClose = page.locator('.fixed.right-4.z-\\[200\\] button')
|
||||
const healthCount = await healthToastClose.count().catch(() => 0)
|
||||
for (let i = 0; i < healthCount; i++) {
|
||||
await healthToastClose.first().click({ timeout: 1_000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function clickWithGuard(page: Page, selector: string, timeoutMs: number): Promise<void> {
|
||||
const attempts = 4
|
||||
const perAttemptMs = Math.max(2_000, Math.floor(timeoutMs / attempts))
|
||||
let lastErr: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
await dismissOverlays(page)
|
||||
try {
|
||||
await page.locator(selector).first().click({ timeout: perAttemptMs, force: i === attempts - 1 })
|
||||
return
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
|
||||
async function clickChain(page: Page, steps: string[], timeoutMs: number): Promise<void> {
|
||||
for (const selector of steps) await clickWithGuard(page, selector, timeoutMs)
|
||||
}
|
||||
|
||||
async function goHome(page: Page, timeoutMs: number): Promise<void> {
|
||||
if (new URL(page.url()).pathname === '/dashboard/chat') {
|
||||
const closed = await page.locator('.chat-close-btn').first().click({ timeout: 5_000 }).then(() => true).catch(() => false)
|
||||
if (!closed) await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
}
|
||||
await clickWithGuard(page, '[data-controller-zone="sidebar"] a[href="/dashboard"]', timeoutMs)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard', { timeout: timeoutMs })
|
||||
}
|
||||
|
||||
interface CpuProfileNode {
|
||||
id: number
|
||||
callFrame: { functionName: string; url: string; lineNumber: number; columnNumber: number; scriptId: string }
|
||||
hitCount?: number
|
||||
children?: number[]
|
||||
}
|
||||
interface CpuProfile {
|
||||
nodes: CpuProfileNode[]
|
||||
startTime: number
|
||||
endTime: number
|
||||
samples?: number[]
|
||||
timeDeltas?: number[]
|
||||
}
|
||||
|
||||
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
|
||||
// 404 on a test node), so minified function names inside vendor-*.js/
|
||||
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
|
||||
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
|
||||
// together; index = app entry/shared code; per-route chunk name = that
|
||||
// view's own lazy-loaded code) rather than by node_modules path, which only
|
||||
// exists pre-build.
|
||||
function categorize(url: string): string {
|
||||
if (!url) return '(native/gc/idle — no JS frame)'
|
||||
const file = url.split('/').pop() ?? url
|
||||
if (/^vendor-/.test(file)) return 'assets/vendor-*.js (Vue/Pinia/vue-router runtime bundle)'
|
||||
if (/^index-/.test(file)) return 'assets/index-*.js (app entry/shared chunk)'
|
||||
if (/^Dashboard-/.test(file)) return 'assets/Dashboard-*.js (Dashboard/DashboardRouterView chunk)'
|
||||
if (/^Fleet-/.test(file)) return 'assets/Fleet-*.js (Fleet.vue chunk)'
|
||||
if (/^Web5-/i.test(file)) return 'assets/Web5-*.js chunk'
|
||||
if (/^Server-/.test(file)) return 'assets/Server-*.js chunk'
|
||||
if (/^Discover-/.test(file)) return 'assets/Discover-*.js chunk'
|
||||
if (/^AppDetails-/.test(file)) return 'assets/AppDetails-*.js chunk'
|
||||
if (/^OpenWrtGateway-/.test(file)) return 'assets/OpenWrtGateway-*.js chunk'
|
||||
if (url.startsWith('assets/')) return `assets/${file} (other chunk)`
|
||||
if (url.includes('node_modules')) return 'other node_modules (dev-only build)'
|
||||
return '(native/gc/idle — no JS frame)'
|
||||
}
|
||||
|
||||
function analyzeProfile(profile: CpuProfile, label: string): void {
|
||||
const byId = new Map<number, CpuProfileNode>()
|
||||
for (const n of profile.nodes) byId.set(n.id, n)
|
||||
|
||||
const selfTimeById = new Map<number, number>()
|
||||
const samples = profile.samples ?? []
|
||||
const timeDeltas = profile.timeDeltas ?? []
|
||||
let total = 0
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const dt = timeDeltas[i] ?? 0
|
||||
total += dt
|
||||
const id = samples[i]!
|
||||
selfTimeById.set(id, (selfTimeById.get(id) ?? 0) + dt)
|
||||
}
|
||||
const totalMs = total / 1000
|
||||
const windowMs = (profile.endTime - profile.startTime) / 1000
|
||||
|
||||
// Aggregate by function identity (name@file:line) and by coarse category.
|
||||
const byFunction = new Map<string, number>()
|
||||
const byCategory = new Map<string, number>()
|
||||
for (const [id, us] of selfTimeById) {
|
||||
const node = byId.get(id)
|
||||
if (!node) continue
|
||||
const fnName = node.callFrame.functionName || '(anonymous)'
|
||||
const file = node.callFrame.url ? node.callFrame.url.split('/').slice(-2).join('/') : '(native)'
|
||||
const key = `${fnName} @ ${file}:${node.callFrame.lineNumber}`
|
||||
byFunction.set(key, (byFunction.get(key) ?? 0) + us)
|
||||
const cat = categorize(node.callFrame.url)
|
||||
byCategory.set(cat, (byCategory.get(cat) ?? 0) + us)
|
||||
}
|
||||
|
||||
console.log(`\n===== CPU PROFILE: ${label} =====`)
|
||||
console.log(`Profiled window (Profiler start->stop): ${windowMs.toFixed(1)}ms`)
|
||||
console.log(`Total sampled self-time: ${totalMs.toFixed(1)}ms (${samples.length} samples)`)
|
||||
console.log(`--- By category (self time) ---`)
|
||||
const catSorted = Array.from(byCategory.entries()).sort((a, b) => b[1] - a[1])
|
||||
for (const [cat, us] of catSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(1)}ms (${((us / total) * 100).toFixed(1)}%) ${cat}`)
|
||||
}
|
||||
console.log(`--- Top 20 functions (self time) ---`)
|
||||
const fnSorted = Array.from(byFunction.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [key, us] of fnSorted) {
|
||||
const ms = us / 1000
|
||||
console.log(` ${ms.toFixed(2)}ms (${((us / total) * 100).toFixed(1)}%) ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test('profile revisit CPU cost for named regressed surfaces', async ({ page }) => {
|
||||
test.setTimeout(15 * 60 * 1000)
|
||||
await login(page)
|
||||
|
||||
for (const id of TARGET_IDS) {
|
||||
const surface = SURFACES.find((s) => s.id === id) as Surface
|
||||
expect(surface, `surface ${id} must exist in SURFACES`).toBeTruthy()
|
||||
|
||||
await goHome(page, NAV_TIMEOUT)
|
||||
|
||||
// First visit — warm the cache (matches the harness's own first-visit/
|
||||
// revisit structure) — not profiled.
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
|
||||
// Away — to the neutral Settings tab (or close, for an in-page trigger).
|
||||
if (surface.closeSelector) {
|
||||
await page.locator(surface.closeSelector).first().click({ timeout: NAV_TIMEOUT })
|
||||
} else {
|
||||
await clickWithGuard(page, NEUTRAL_SELECTOR, NAV_TIMEOUT)
|
||||
await page.waitForURL((url) => url.pathname === '/dashboard/settings', { timeout: NAV_TIMEOUT })
|
||||
}
|
||||
// Let the away-transition settle before starting the profiler so it
|
||||
// captures only the revisit window, not Settings' own leave-transition.
|
||||
await page.waitForTimeout(600)
|
||||
|
||||
// Instrument window.setTimeout/requestAnimationFrame for the profiled
|
||||
// window only, so a large "idle" share in the CPU profile can be
|
||||
// attributed to a concrete scheduled delay (app timer) vs. a chain of
|
||||
// animation frames (CSS-transition-bound) vs. neither (network wait).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
w.__timerLog = []
|
||||
w.__origSetTimeout = window.setTimeout.bind(window)
|
||||
w.__origRaf = window.requestAnimationFrame.bind(window)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setTimeout = (fn: TimerHandler, delay?: number, ...args: unknown[]) => {
|
||||
w.__timerLog.push({ type: 'setTimeout', delay, at: performance.now() })
|
||||
return w.__origSetTimeout(fn as any, delay, ...args) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
|
||||
w.__timerLog.push({ type: 'raf', at: performance.now() })
|
||||
return w.__origRaf(cb)
|
||||
}
|
||||
})
|
||||
|
||||
// Wall-clock RPC start/finish (matches measure.ts's own attachRpcTracker
|
||||
// convention: Date.now() offsets, method name only, no bodies — T-02-06).
|
||||
const rpcCalls: Array<{ method: string; startedAtMs: number; durationMs: number | null }> = []
|
||||
const pendingRpc = new Map<import('@playwright/test').Request, { method: string; start: number }>()
|
||||
let tRpc0 = 0
|
||||
const isRpc = (req: import('@playwright/test').Request) => req.method() === 'POST' && req.url().includes('/rpc/v1')
|
||||
const onReq = (req: import('@playwright/test').Request) => {
|
||||
if (!isRpc(req)) return
|
||||
let method = 'unknown'
|
||||
try {
|
||||
const body = req.postData()
|
||||
if (body) method = (JSON.parse(body) as { method?: string }).method ?? 'unknown'
|
||||
} catch {
|
||||
/* keep 'unknown' */
|
||||
}
|
||||
pendingRpc.set(req, { method, start: Date.now() - tRpc0 })
|
||||
}
|
||||
const onSettled = (req: import('@playwright/test').Request) => {
|
||||
const info = pendingRpc.get(req)
|
||||
if (!info) return
|
||||
pendingRpc.delete(req)
|
||||
rpcCalls.push({ method: info.method, startedAtMs: info.start, durationMs: Date.now() - tRpc0 - info.start })
|
||||
}
|
||||
page.on('request', onReq)
|
||||
page.on('requestfinished', onSettled)
|
||||
page.on('requestfailed', onSettled)
|
||||
|
||||
// Capture every CSS transition/animation start+end on the document during
|
||||
// the window — decisive evidence for/against "a long CSS transition is
|
||||
// what the human waits through" (Playwright's own 'visible' check does
|
||||
// NOT wait for transitions/opacity, only a non-empty bounding box + not
|
||||
// visibility:hidden, so a slow transition would NOT show up as CPU cost
|
||||
// or as a blocked contentSelector — it would show up here, and would
|
||||
// explain a "feels slow" gap between first-paint and contentSelector).
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }
|
||||
w.__animLog = []
|
||||
const describe = (el: EventTarget | null): string => {
|
||||
const e = el as HTMLElement | null
|
||||
if (!e || !e.tagName) return '(unknown)'
|
||||
const cls = (e.className && typeof e.className === 'string') ? '.' + e.className.trim().split(/\s+/).slice(0, 2).join('.') : ''
|
||||
return `${e.tagName.toLowerCase()}${cls}`
|
||||
}
|
||||
const log = (type: string) => (ev: Event) => {
|
||||
const te = ev as TransitionEvent | AnimationEvent
|
||||
w.__animLog.push({
|
||||
type,
|
||||
name: (te as TransitionEvent).propertyName ?? (te as AnimationEvent).animationName ?? '',
|
||||
target: describe(ev.target),
|
||||
elapsedMs: Math.round((te.elapsedTime ?? 0) * 1000),
|
||||
at: performance.now(),
|
||||
})
|
||||
}
|
||||
document.addEventListener('transitionrun', log('transitionrun'), true)
|
||||
document.addEventListener('transitionend', log('transitionend'), true)
|
||||
document.addEventListener('transitioncancel', log('transitioncancel'), true)
|
||||
document.addEventListener('animationstart', log('animationstart'), true)
|
||||
document.addEventListener('animationend', log('animationend'), true)
|
||||
})
|
||||
|
||||
const client = await page.context().newCDPSession(page)
|
||||
await client.send('Profiler.enable')
|
||||
await client.send('Profiler.setSamplingInterval', { interval: 100 })
|
||||
await client.send('Profiler.start')
|
||||
|
||||
// Raw Chrome trace events (the SAME data DevTools' Performance panel
|
||||
// renders as a flame chart / summary tab) — categorizes rendering work
|
||||
// (Layout, RecalculateStyles, Paint, CompositeLayers, RunTask, TimerFire,
|
||||
// FireAnimationFrame, ...) that a bare JS CPU profile only sees as
|
||||
// "(program)"/"(idle)" because layout/paint/compositing run on the same
|
||||
// renderer main thread but outside any JS call frame.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const traceEvents: any[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client.on('Tracing.dataCollected', (data: any) => { traceEvents.push(...(data.value ?? [])) })
|
||||
const tracingComplete = new Promise<void>((resolve) => client.once('Tracing.tracingComplete', () => resolve()))
|
||||
await client.send('Tracing.start', {
|
||||
categories: 'disabled-by-default-devtools.timeline,devtools.timeline,toplevel,v8,blink.user_timing',
|
||||
transferMode: 'ReportEvents',
|
||||
})
|
||||
|
||||
// First-paint probe, independent of Playwright's own contentSelector
|
||||
// wait: records performance.now() the moment contentSelector FIRST gets
|
||||
// a non-empty bounding box, polled every animation frame (not gated by
|
||||
// Playwright's stricter "visible" actionability rules) — so first-paint
|
||||
// and content-visible can be reported as two distinct numbers per the
|
||||
// gap plan's explicit ask.
|
||||
const pageT0 = await page.evaluate((sel) => {
|
||||
const w = window as unknown as { __firstPaintAt: number | null }
|
||||
w.__firstPaintAt = null
|
||||
function poll() {
|
||||
if (w.__firstPaintAt == null) {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) {
|
||||
const r = el.getBoundingClientRect()
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
w.__firstPaintAt = performance.now()
|
||||
return
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(poll)
|
||||
return performance.now()
|
||||
}, surface.contentSelector)
|
||||
|
||||
const t0 = Date.now()
|
||||
tRpc0 = t0
|
||||
await clickChain(page, surface.navSteps, NAV_TIMEOUT)
|
||||
await page.locator(surface.contentSelector).first().waitFor({ state: 'visible', timeout: CONTENT_TIMEOUT })
|
||||
const wallMs = Date.now() - t0
|
||||
const firstPaintAt = await page.evaluate(() => (window as unknown as { __firstPaintAt: number | null }).__firstPaintAt)
|
||||
const firstPaintMs = firstPaintAt != null ? Math.round(firstPaintAt - pageT0) : null
|
||||
|
||||
const { profile } = await client.send('Profiler.stop')
|
||||
await client.send('Profiler.disable')
|
||||
await client.send('Tracing.end')
|
||||
await tracingComplete
|
||||
await client.detach().catch(() => {})
|
||||
page.off('request', onReq)
|
||||
page.off('requestfinished', onSettled)
|
||||
page.off('requestfailed', onSettled)
|
||||
|
||||
// Find the renderer main-thread tid (thread_name metadata event) so the
|
||||
// breakdown below isn't polluted by compositor/IO/GPU-process threads.
|
||||
const mainThreadMeta = traceEvents.find(
|
||||
(e) => e.ph === 'M' && e.name === 'thread_name' && (e.args?.name === 'CrRendererMain')
|
||||
)
|
||||
const mainTid = mainThreadMeta?.tid
|
||||
const durByName = new Map<string, number>()
|
||||
for (const e of traceEvents) {
|
||||
if (mainTid != null && e.tid !== mainTid) continue
|
||||
if (e.ph !== 'X') continue // complete events only (have a real duration)
|
||||
if (typeof e.dur !== 'number') continue
|
||||
durByName.set(e.name, (durByName.get(e.name) ?? 0) + e.dur)
|
||||
}
|
||||
console.log(`--- Chrome trace event self/total time by name (main thread, category=devtools.timeline; NOTE: 'X' events can nest, so this is TOTAL not self time — use for relative magnitude, not a sum-to-100% budget) ---`)
|
||||
const traceSorted = Array.from(durByName.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20)
|
||||
for (const [name, us] of traceSorted) {
|
||||
console.log(` ${(us / 1000).toFixed(1)}ms ${name}`)
|
||||
}
|
||||
|
||||
const timerLog = await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__timerLog: Array<{ type: string; delay?: number; at: number }>
|
||||
__origSetTimeout: typeof setTimeout
|
||||
__origRaf: typeof requestAnimationFrame
|
||||
}
|
||||
const log = w.__timerLog ?? []
|
||||
window.setTimeout = w.__origSetTimeout
|
||||
window.requestAnimationFrame = w.__origRaf
|
||||
return log
|
||||
})
|
||||
|
||||
const animLog = await page.evaluate(() => (window as unknown as { __animLog: Array<{ type: string; name: string; target: string; elapsedMs: number; at: number }> }).__animLog ?? [])
|
||||
|
||||
console.log(`\n>>> ${surface.label} (${surface.id}) revisit wall-clock: ${wallMs}ms | first-paint: ${firstPaintMs}ms (contentSelector's first non-empty bounding box, unrelated to Playwright's own stricter 'visible' check)`)
|
||||
analyzeProfile(profile as CpuProfile, `${surface.label} (${surface.id})`)
|
||||
|
||||
const setTimeoutEntries = timerLog.filter((e) => e.type === 'setTimeout')
|
||||
const rafCount = timerLog.filter((e) => e.type === 'raf').length
|
||||
console.log(`--- Timers scheduled during the revisit window (app-code setTimeout/rAF calls) ---`)
|
||||
console.log(` setTimeout calls: ${setTimeoutEntries.length}${setTimeoutEntries.length ? ' — delays(ms): ' + setTimeoutEntries.map((e) => e.delay ?? 0).sort((a, b) => a - b).join(',') : ''}`)
|
||||
console.log(` requestAnimationFrame calls: ${rafCount}`)
|
||||
|
||||
console.log(`--- CSS transition/animation events during the window (relative to window start, pageT0) ---`)
|
||||
if (animLog.length === 0) {
|
||||
console.log(' (none observed)')
|
||||
} else {
|
||||
for (const e of animLog) {
|
||||
console.log(` +${Math.round(e.at - pageT0)}ms ${e.type} name="${e.name}" target=${e.target} elapsed=${e.elapsedMs}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`--- RPC calls during the revisit window (wall-clock ms from window start) ---`)
|
||||
for (const c of rpcCalls) {
|
||||
console.log(` ${c.method}: start=+${c.startedAtMs}ms duration=${c.durationMs}ms`)
|
||||
}
|
||||
console.log(` total RPC calls: ${rpcCalls.length}, wall-clock window: ${wallMs}ms`)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
// Re-runnable surface-perf harness (02-01-PLAN.md Task 1). Logs in using the
|
||||
// exact flow from app-launch.spec.ts, walks every SURFACES row via
|
||||
// measureSurface(), and writes the full result array + a run header to
|
||||
// ARCHY_PERF_OUT (defaulting to e2e/test-results/surface-perf.json).
|
||||
//
|
||||
// Redaction is structural, not a cleanup pass: measure.ts's RpcTracker only
|
||||
// ever records method name + timing (T-02-06) — request/response bodies,
|
||||
// page text and screenshots are never captured into the artifact.
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { measureSurface, type SurfaceMeasurement } from './measure'
|
||||
import { SURFACES } from './surfaces'
|
||||
|
||||
const PASSWORD = process.env.ARCHY_PASSWORD ?? 'password123'
|
||||
const RUNS = process.env.ARCHY_PERF_RUNS ? Number(process.env.ARCHY_PERF_RUNS) : 3
|
||||
const OUT_PATH = resolve(process.cwd(), process.env.ARCHY_PERF_OUT ?? 'e2e/test-results/surface-perf.json')
|
||||
|
||||
async function login(page: Page): Promise<void> {
|
||||
// Mirrors e2e/app-launch.spec.ts's login() verbatim — do not invent a
|
||||
// second auth path.
|
||||
await page.goto('/login', { waitUntil: 'domcontentloaded' })
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('neode_intro_seen', '1')
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
})
|
||||
await page.goto('/login', { waitUntil: 'networkidle' })
|
||||
|
||||
const passwordInput = page.locator('input[type="password"]').first()
|
||||
await passwordInput.waitFor({ timeout: 15_000 })
|
||||
await passwordInput.fill(PASSWORD)
|
||||
await page
|
||||
.locator('button:has-text("Login"), button:has-text("Unlock"), button:has-text("Continue"), button[type="submit"]')
|
||||
.first()
|
||||
.click()
|
||||
await page.waitForURL('**/dashboard**', { timeout: 20_000 })
|
||||
}
|
||||
|
||||
function currentCommit(): string {
|
||||
try {
|
||||
// `__dirname` is unavailable under this package's `"type": "module"` ESM
|
||||
// runtime (Playwright's own transform swallows the ReferenceError into
|
||||
// the catch below, silently yielding 'unknown') — use `process.cwd()`
|
||||
// instead, which Playwright always sets to the project root it was
|
||||
// invoked from.
|
||||
return execSync('git rev-parse --short HEAD', { cwd: process.cwd() }).toString().trim()
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
test('surface-perf: measure every D-09 surface and write the baseline artifact', async ({ page, baseURL }) => {
|
||||
test.setTimeout(20 * 60 * 1000) // 15 surfaces x 3 runs x network round-trips can run long on a real node
|
||||
|
||||
await login(page)
|
||||
|
||||
const results: SurfaceMeasurement[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
try {
|
||||
const measurement = await measureSurface(page, surface, { runs: RUNS })
|
||||
results.push(measurement)
|
||||
if (measurement.error) skipped.push(`${surface.id}: ${measurement.error}`)
|
||||
} catch (err) {
|
||||
// A surface that throws outside measureSurface's own per-run try/catch
|
||||
// (e.g. login state got corrupted) is still recorded, never dropped —
|
||||
// an unmeasured surface must never silently disappear from the array.
|
||||
results.push({
|
||||
id: surface.id,
|
||||
label: surface.label,
|
||||
path: surface.path,
|
||||
kind: surface.kind,
|
||||
runs: RUNS,
|
||||
samples: [],
|
||||
firstVisitMs: null,
|
||||
revisitMs: null,
|
||||
firstVisitRpcCount: null,
|
||||
revisitRpcCount: null,
|
||||
revisitRpcCalls: [],
|
||||
maxConcurrentRpc: null,
|
||||
rpcWallClockMs: null,
|
||||
remounted: null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
skipped.push(`${surface.id}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const header = {
|
||||
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
|
||||
takenAt: new Date().toISOString(),
|
||||
commit: currentCommit(),
|
||||
runs: RUNS,
|
||||
notes: skipped.length > 0 ? `Skipped/errored surfaces: ${skipped.join('; ')}` : 'All surfaces measured cleanly.',
|
||||
}
|
||||
|
||||
const artifact = { ...header, results }
|
||||
|
||||
mkdirSync(dirname(OUT_PATH), { recursive: true })
|
||||
writeFileSync(OUT_PATH, JSON.stringify(artifact, null, 2))
|
||||
|
||||
expect(results.length).toBe(SURFACES.length)
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
// SURFACES table for the Phase 02 UI-performance profiling harness (02-01-PLAN.md,
|
||||
// Task 1). One row per D-09 surface. Every `path` here must exist in
|
||||
// `neode-ui/src/router/index.ts` (verified by acceptance criteria).
|
||||
//
|
||||
// `navSteps` are ordered, real UI-click selectors — clicking an <a>/<RouterLink>
|
||||
// or a button that calls `router.push()` triggers genuine client-side SPA
|
||||
// navigation (no full page reload), which is what "remount storm" measurement
|
||||
// requires. `page.goto()` is deliberately NOT used to move between dashboard
|
||||
// surfaces — it would force a full reload every time and always show
|
||||
// remounted=true regardless of whether KeepAlive would have helped.
|
||||
//
|
||||
// `contentSelector` is chosen to be present only once real content has
|
||||
// painted (never a loading skeleton/spinner). `rootSelector` is the stable
|
||||
// outermost element of the mounted view, used by the remount probe in
|
||||
// measure.ts. `.view-container` is a class Dashboard.vue's nested
|
||||
// `<router-view>` merges onto every non-chat/non-mesh view's root element via
|
||||
// Vue's automatic fallthrough-attribute merging (verified: `grep -rn
|
||||
// "view-container" src` shows it added only by Dashboard.vue's default
|
||||
// branch) — so it works as a uniform root selector across most surfaces.
|
||||
// Mesh and Chat take Dashboard.vue's other template branch (no class
|
||||
// fallthrough) so they use their own static root class instead.
|
||||
|
||||
export type SurfaceKind = 'main-tab' | 'secondary'
|
||||
|
||||
export interface Surface {
|
||||
id: string
|
||||
label: string
|
||||
/** Route path, must exist in router/index.ts. */
|
||||
path: string
|
||||
kind: SurfaceKind
|
||||
/** A selector present only once real content has painted (not a skeleton/spinner). */
|
||||
contentSelector: string
|
||||
/** The stable outermost element of the view, used for the remount probe. */
|
||||
rootSelector: string
|
||||
/**
|
||||
* Ordered selectors clicked (via real UI interaction) to reach/open this
|
||||
* surface. Every step is resolved with `.first()` and clicked; the chain
|
||||
* is re-run unchanged for the revisit measurement.
|
||||
*/
|
||||
navSteps: string[]
|
||||
/**
|
||||
* When set, this surface is an in-page trigger (e.g. a modal) rather than
|
||||
* a distinct navigable route (D-09's "Wallet / send flows" — no Wallet.vue
|
||||
* exists; the real surface is the Send button on the Home dashboard wallet
|
||||
* card, opening SendBitcoinModal). Revisit is measured by clicking this to
|
||||
* close, then re-running the last `navSteps` entry to reopen — "open to
|
||||
* content" rather than "navigate to content" per the plan's fallback rule.
|
||||
*/
|
||||
closeSelector?: string
|
||||
}
|
||||
|
||||
const SIDEBAR = '[data-controller-zone="sidebar"]'
|
||||
|
||||
export const SURFACES: Surface[] = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home (wallet figures)',
|
||||
path: '/dashboard',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.home-card',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`],
|
||||
},
|
||||
{
|
||||
id: 'apps',
|
||||
label: 'Apps (My Apps)',
|
||||
path: '/dashboard/apps',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.apps-card-grid-desktop [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`],
|
||||
},
|
||||
{
|
||||
id: 'marketplace',
|
||||
label: 'Marketplace (App Store)',
|
||||
path: '/dashboard/marketplace',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.marketplace-container [data-controller-container]',
|
||||
// Marketplace has no sidebar entry — reached via Home's "Browse Store" link.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'a:has-text("Browse Store")'],
|
||||
},
|
||||
{
|
||||
id: 'discover',
|
||||
label: 'Discover (App Store tab)',
|
||||
path: '/dashboard/discover',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.discover-container [data-controller-container]',
|
||||
// Discover has no sidebar entry — reached via the "App Store" tab inside Apps.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-view a:has-text("App Store")'],
|
||||
},
|
||||
{
|
||||
id: 'cloud',
|
||||
label: 'Cloud / Files',
|
||||
path: '/dashboard/cloud',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.apps-view [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`],
|
||||
},
|
||||
{
|
||||
id: 'mesh',
|
||||
label: 'Mesh',
|
||||
path: '/dashboard/mesh',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.mesh-view',
|
||||
contentSelector: '.mesh-status-grid',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/mesh"]`],
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
label: 'Server (Network)',
|
||||
path: '/dashboard/server',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`],
|
||||
},
|
||||
{
|
||||
id: 'web5',
|
||||
label: 'Web5',
|
||||
path: '/dashboard/web5',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`],
|
||||
},
|
||||
{
|
||||
id: 'fleet',
|
||||
label: 'Fleet',
|
||||
path: '/dashboard/fleet',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.view-container [data-controller-container]',
|
||||
// Fleet's sidebar entry is hidden for beta — reached via Web5's Federation
|
||||
// card link. Web5Federation.vue renders TWO "Fleet" links: a
|
||||
// `.web5-card-actions-top` one that CSS permanently hides
|
||||
// (`display: none` — the "compact header variants are permanently
|
||||
// retired" rule in style.css) and the real, visible one inside
|
||||
// `.web5-card-actions-bottom-grid`. Scope to the latter so `.first()`
|
||||
// doesn't land on the hidden copy.
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/web5"]`, '.web5-card-actions-bottom-grid a:has-text("Fleet")'],
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat (AIUI)',
|
||||
path: '/dashboard/chat',
|
||||
kind: 'main-tab',
|
||||
rootSelector: '.chat-fullscreen',
|
||||
contentSelector: '.chat-iframe, .chat-placeholder',
|
||||
navSteps: [`${SIDEBAR} button:has-text("AIUI")`],
|
||||
// DashboardSidebar.vue is `v-show="!chatFullscreen"` — the sidebar (and
|
||||
// therefore the neutral Settings link) is not visible while on Chat, so
|
||||
// the normal "away via sidebar" step is impossible here. Chat's own close
|
||||
// button calls closeChat() (router.back(), landing on wherever we came
|
||||
// from — Home, per navSteps below), which is the surface's real "away".
|
||||
closeSelector: '.chat-close-btn',
|
||||
},
|
||||
{
|
||||
id: 'app-details',
|
||||
label: 'AppDetails (secondary)',
|
||||
path: '/dashboard/apps/:id',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.app-details-container h1',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/apps"]`, '.apps-card-grid-desktop [data-controller-container]'],
|
||||
},
|
||||
{
|
||||
id: 'marketplace-app-details',
|
||||
label: 'MarketplaceAppDetails (secondary)',
|
||||
path: '/dashboard/marketplace/:id',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.app-details-container h1',
|
||||
navSteps: [
|
||||
`${SIDEBAR} a[href="/dashboard"]`,
|
||||
'a:has-text("Browse Store")',
|
||||
'.marketplace-container [data-controller-container]',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cloud-folder',
|
||||
label: 'CloudFolder (secondary)',
|
||||
path: '/dashboard/cloud/:folderId',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: '.cloud-folder-container h1',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/cloud"]`, '.apps-view [data-controller-container]'],
|
||||
},
|
||||
{
|
||||
id: 'openwrt-gateway',
|
||||
label: 'OpenWrtGateway (secondary)',
|
||||
path: '/dashboard/server/openwrt',
|
||||
kind: 'secondary',
|
||||
rootSelector: '.view-container',
|
||||
contentSelector: 'h1:has-text("OpenWrt Gateway")',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard/server"]`, 'a:has-text("OpenWrt Gateway")'],
|
||||
},
|
||||
{
|
||||
id: 'wallet-send',
|
||||
label: 'Wallet / send flow (Home wallet card, SendBitcoinModal)',
|
||||
// Not a route — RESEARCH.md found no Wallet.vue; the real surface is a
|
||||
// modal opened from the Home wallet card. Path recorded for reference
|
||||
// only (the page the trigger lives on).
|
||||
path: '/dashboard',
|
||||
kind: 'secondary',
|
||||
rootSelector: '[role="dialog"]',
|
||||
contentSelector: 'h3:has-text("Send Bitcoin")',
|
||||
navSteps: [`${SIDEBAR} a[href="/dashboard"]`, 'button:has-text("Send")'],
|
||||
closeSelector: '[role="dialog"] button[aria-label="Close"]',
|
||||
},
|
||||
]
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 785 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 571 KiB |
|
After Width: | Height: | Size: 610 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icon/favico-black-v2.svg" />
|
||||
<link rel="icon" href="/favicon-v2.ico" sizes="48x48" />
|
||||
<link rel="icon" type="image/png" sizes="64x64" href="/assets/icon/pwa-64x64-v2.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon/pwa-192x192-v2.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/assets/icon/pwa-512x512-v2.png" />
|
||||
<link rel="apple-touch-icon" href="/assets/icon/apple-touch-icon-180x180-v2.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/assets/icon/apple-touch-icon-180x180-v2.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/assets/icon/pwa-192x192-v2.png" />
|
||||
<link rel="apple-touch-icon" sizes="512x512" href="/assets/icon/pwa-512x512-v2.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Archipelago - Your sovereign personal server" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!-- Dark-only app: pin native controls dark before CSS loads. -->
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Archipelago" />
|
||||
<meta name="application-name" content="Archipelago" />
|
||||
<meta name="msapplication-TileColor" content="#000000" />
|
||||
<meta name="msapplication-TileImage" content="/assets/icon/pwa-192x192-v2.png" />
|
||||
<title>Archipelago OS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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)."
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.127-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
"stop": "./stop-dev.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
||||
"dev": "vite",
|
||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
|
||||
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
||||
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
|
||||
"backend:mock": "node mock-backend.js",
|
||||
"backend:real": "cd ../core && cargo run --release",
|
||||
"prebuild": "cp ../app-catalog/catalog.json public/catalog.json",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:docker": "vite build",
|
||||
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"generate-pwa-icons": "pwa-assets-generator --preset minimal-2023 public/assets/icon/favico-black.svg && cp public/assets/icon/favicon.ico public/favicon.ico",
|
||||
"generate-welcome-speech": "node scripts/generate-welcome-speech.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@vue-leaflet/vue-leaflet": "^0.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"pinia": "^3.0.4",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrloop": "^1.4.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"concurrently": "^9.1.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dockerode": "^4.0.9",
|
||||
"express": "^4.21.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^3.1.1",
|
||||
"vue-tsc": "^3.1.3",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
outputDir: './e2e/test-results',
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'off',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { browserName: 'chromium' },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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 & Receipts</a>
|
||||
<a href="#msg-content">Content / Files</a>
|
||||
<a href="#msg-bitcoin">Bitcoin & Lightning</a>
|
||||
<a href="#msg-safety">Alerts & Presence</a>
|
||||
<a href="#msg-identity">Identity & 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 & 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 5–15 km with a simple antenna; data rates are 0.3–50 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 & 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>'<'</code> and listens for <code>'>'</code>.</p>
|
||||
|
||||
<div class="diagram">Host → Device: <span class="highlight">0x3C</span> '<' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||
Device → Host: <span class="highlight">0x3E</span> '>' │ <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>< 0x80</code> are replies to a command we sent; codes <code>>= 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 > 160 B && 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 >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 <name>". 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 & 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 1–6 | `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.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Icon Attribution
|
||||
|
||||
- `barbarian.svg`, `batteries.svg` — from **game-icons.net**
|
||||
(https://game-icons.net), by Lorc, Delapouite & contributors,
|
||||
licensed under CC BY 3.0 (https://creativecommons.org/licenses/by/3.0/).
|
||||
- Pixel-style icons (`save.svg`, `paint-bucket.svg`, `fill-half.svg`,
|
||||
`cloud-moon.svg`, `debug-off.svg`, `cloud-done.svg`) — from
|
||||
**pixelarticons** by Gerrit Halfmann (https://github.com/halfmage/pixelarticons),
|
||||
MIT License.
|
||||
- All other icons are original Archipelago artwork (MIT, see repository
|
||||
LICENSE).
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
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 |
@@ -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 |