Compare commits
130 Commits
e8e57ae4b0
...
grok/issue
| Author | SHA1 | Date | |
|---|---|---|---|
| 481a0790d2 | |||
| 35dd6c5f17 | |||
| 02c8c351b1 | |||
| 8580c6754b | |||
| e970746c28 | |||
| ee9d5b0108 | |||
| 6e65508dff | |||
| 9a55794441 | |||
| 65d7d44ea1 | |||
| a56fe611a9 | |||
| 27da609d4a | |||
| 677a9e5ae8 | |||
| 9ec5c52936 | |||
| 05bd7ffec7 | |||
| e29b6ff0a8 | |||
|
|
0a49e6e75d | ||
| 6d2a136baf | |||
| 0c7fb43b2d | |||
| 024d3a458a | |||
| b68d874cdc | |||
| f14a81cd22 | |||
| 2f633c566d | |||
| fda629162c | |||
| 4f5c2d899b | |||
| d035f90d09 | |||
| ea3df7b9b5 | |||
| c70b6e87be | |||
| b6b5d7817f | |||
| 241e6f1e33 | |||
|
|
92a13caf5a | ||
| 08d83f9bcb | |||
| 611ba9790f | |||
| 14b118f03d | |||
| f5feaf4ded | |||
| a7c13aac1e | |||
| 29ae0296d4 | |||
| c6db04a145 | |||
| 3829e946ff | |||
| e4fb30a4a6 | |||
| 51967280a9 | |||
| f6a797c3c3 | |||
| 790d5e0520 | |||
| 341e3ba3bb | |||
| e67e583403 | |||
| fa94d623d1 | |||
| 0a217401fb | |||
| 0073f818b2 | |||
| 343af432a4 | |||
| cab1ab7060 | |||
| 68aca2c23d | |||
| 5e415c788f | |||
| 351d5aaeed | |||
| d2b483deca | |||
| 7d40177502 | |||
| 9647e94b0c | |||
| a8f602a1da | |||
| 668a69ecc9 | |||
| 19fc983ef0 | |||
| 82e67960e2 | |||
| 1ca8f1e8e2 | |||
| 459b3eb38f | |||
| fcb198f55d | |||
| c24b69359f | |||
| 2a19b8f156 | |||
| 3614886fad | |||
| 1780011c8b | |||
| 548a59c5a6 | |||
| b1fc67fc2f | |||
| 17259ec1d4 | |||
| 6213b36d66 | |||
| 5794c7ed71 | |||
| fb75a0b199 | |||
| 1f005b8e64 | |||
| db8e9802bc | |||
| b10f23c12d | |||
| 0711ef03a7 | |||
| 63aa9e7ef4 | |||
| 409191e250 | |||
| beee17f43c | |||
| e6a72ec7da | |||
| 31b05e3549 | |||
| 36945e7302 | |||
| 36edceae42 | |||
| dc02d8fdc5 | |||
| a5b820d6fc | |||
| 33d95fd271 | |||
| b7c5f29084 | |||
| 18c4deef74 | |||
| 39e0eecb9e | |||
| d193a89262 | |||
| cb2749119e | |||
| eadc104842 | |||
| b8d6f2881c | |||
| 773d5b6a73 | |||
| d3b5f450f6 | |||
| 1dc82b656f | |||
| c082f32180 | |||
| 2ba19f4bc3 | |||
| b61f651226 | |||
| e290de5987 | |||
| 60bc437cfb | |||
| 36cc526df0 | |||
| 8407c0d7bf | |||
|
|
5dd486e9b8 | ||
| 440e31e36f | |||
| 2ebd153493 | |||
| 4f853aae51 | |||
| 316ce63605 | |||
| 7eca0fba5d | |||
| 1b5e9dbce0 | |||
| 3934a7b488 | |||
| 554a4a030e | |||
| 8767f2c5d2 | |||
| 4c4b77669d | |||
| b40b7d9c6c | |||
| db354e84f2 | |||
| a377da05de | |||
| 75c9a3774b | |||
| 96663e1500 | |||
| 58038f2e41 | |||
| d0edfe8725 | |||
|
|
e293fbf7e4 | ||
|
|
4f137aa507 | ||
|
|
6587869984 | ||
| b2c442d495 | |||
| 367e637531 | |||
|
|
1a75ed0f73 | ||
| 3ea10209bc | |||
| 2ea5ad3780 | |||
| c3ce95b9e7 |
10
.gitea/workflows/auto-merge.yml
Normal file
10
.gitea/workflows/auto-merge.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
# Placeholder — auto-merge is handled by nexus-merge-bot.sh
|
||||
# Gitea Actions requires a runner to be registered.
|
||||
# When a runner is available, this can replace the bot.
|
||||
name: stub
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
noop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "See nexus-merge-bot.sh"
|
||||
104
.gitea/workflows/ci.yml
Normal file
104
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Validate HTML
|
||||
run: |
|
||||
test -f index.html || { echo "ERROR: index.html missing"; exit 1; }
|
||||
python3 -c "
|
||||
import html.parser, sys
|
||||
class V(html.parser.HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def handle_starttag(self, tag, attrs): pass
|
||||
def handle_endtag(self, tag): pass
|
||||
v = V()
|
||||
try:
|
||||
v.feed(open('index.html').read())
|
||||
print('HTML: OK')
|
||||
except Exception as e:
|
||||
print(f'HTML: FAIL - {e}')
|
||||
sys.exit(1)
|
||||
"
|
||||
|
||||
- name: Validate JavaScript
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in $(find . -name '*.js' -not -path './node_modules/*' -not -name 'sw.js'); do
|
||||
if ! node --check "$f" 2>/dev/null; then
|
||||
echo "FAIL: $f"
|
||||
FAIL=1
|
||||
else
|
||||
echo "OK: $f"
|
||||
fi
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
- name: Validate JSON
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in $(find . -name '*.json' -not -path './node_modules/*'); do
|
||||
if ! python3 -c "import json; json.load(open('$f'))"; then
|
||||
echo "FAIL: $f"
|
||||
FAIL=1
|
||||
else
|
||||
echo "OK: $f"
|
||||
fi
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
- name: Check file size budget
|
||||
run: |
|
||||
FAIL=0
|
||||
for f in $(find . -name '*.js' -not -path './node_modules/*'); do
|
||||
SIZE=$(wc -c < "$f")
|
||||
if [ "$SIZE" -gt 512000 ]; then
|
||||
echo "FAIL: $f is ${SIZE} bytes (budget: 512000)"
|
||||
FAIL=1
|
||||
else
|
||||
echo "OK: $f (${SIZE} bytes)"
|
||||
fi
|
||||
done
|
||||
exit $FAIL
|
||||
|
||||
auto-merge:
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge PR
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.MERGE_TOKEN }}
|
||||
run: |
|
||||
PR_NUM=$(echo "${{ github.event.pull_request.number }}")
|
||||
REPO="${{ github.repository }}"
|
||||
API="http://143.198.27.163:3000/api/v1"
|
||||
|
||||
echo "CI passed. Auto-merging PR #${PR_NUM}..."
|
||||
|
||||
# Squash merge
|
||||
RESULT=$(curl -s -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do":"squash","delete_branch_after_merge":true}' \
|
||||
"${API}/repos/${REPO}/pulls/${PR_NUM}/merge")
|
||||
|
||||
HTTP_CODE=$(echo "$RESULT" | tail -1)
|
||||
BODY=$(echo "$RESULT" | head -n -1)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "405" ]; then
|
||||
echo "Merged successfully (or already merged)"
|
||||
else
|
||||
echo "Merge failed: HTTP ${HTTP_CODE}"
|
||||
echo "$BODY"
|
||||
# Don't fail the job — PR stays open for manual review
|
||||
fi
|
||||
@@ -12,22 +12,15 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to production
|
||||
# SSH into the host and redeploy via docker compose.
|
||||
# Set DEPLOY_HOST, DEPLOY_USER, and DEPLOY_SSH_KEY in repo secrets.
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
USER: ${{ secrets.DEPLOY_USER }}
|
||||
REPO_DIR: ${{ secrets.DEPLOY_REPO_DIR || '/opt/nexus' }}
|
||||
run: |
|
||||
if [ -z "$SSH_KEY" ] || [ -z "$HOST" ] || [ -z "$USER" ]; then
|
||||
echo "Deploy secrets not configured — skipping remote deploy."
|
||||
echo "Set DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY in repo settings."
|
||||
exit 0
|
||||
fi
|
||||
echo "$SSH_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key "$USER@$HOST" \
|
||||
"cd $REPO_DIR && git pull origin main && docker compose up -d --build nexus"
|
||||
rm /tmp/deploy_key
|
||||
- name: Deploy to host via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
cd ~/the-nexus || git clone http://143.198.27.163:3000/Timmy_Foundation/the-nexus.git ~/the-nexus
|
||||
cd ~/the-nexus
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
./deploy.sh main
|
||||
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.aider*
|
||||
250
CLAUDE.md
Normal file
250
CLAUDE.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# CLAUDE.md — The Nexus (Timmy_Foundation/the-nexus)
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Nexus is a Three.js environment — Timmy's sovereign home in 3D space. It serves as the central hub for all portals to other worlds. Stack: vanilla JS ES modules, Three.js 0.183, no bundler.
|
||||
|
||||
## Architecture
|
||||
|
||||
**app.js is a thin orchestrator. It should almost never change.**
|
||||
|
||||
All logic lives in ES modules under `modules/`. app.js only imports modules, wires them to the ticker, and starts the loop. New features go in new modules — not in app.js.
|
||||
|
||||
```
|
||||
index.html # Entry point: HUD, chat panel, loading screen
|
||||
style.css # Design system: dark space theme, holographic panels
|
||||
app.js # THIN ORCHESTRATOR — imports + init + ticker start (~200 lines)
|
||||
modules/
|
||||
core/
|
||||
scene.js # THREE.Scene, camera, renderer, controls, resize
|
||||
ticker.js # Global Animation Clock — the single RAF loop
|
||||
theme.js # NEXUS.theme — colors, fonts, line weights, glow params
|
||||
state.js # Shared data bus (activity, weather, BTC, agents)
|
||||
audio.js # Web Audio: reverb, panner, ambient, portal hums
|
||||
data/
|
||||
gitea.js # All Gitea API calls (commits, PRs, agents)
|
||||
weather.js # Open-Meteo weather fetch
|
||||
bitcoin.js # Blockstream BTC block height
|
||||
loaders.js # JSON file loaders (portals, sovereignty, SOUL)
|
||||
panels/
|
||||
heatmap.js # Commit heatmap + zone rendering
|
||||
agent-board.js # Agent status board (Gitea API)
|
||||
dual-brain.js # Dual-brain panel (honest offline)
|
||||
lora-panel.js # LoRA adapter panel (honest empty)
|
||||
sovereignty.js # Sovereignty meter + score arc
|
||||
earth.js # Holographic earth (activity-tethered)
|
||||
effects/
|
||||
matrix-rain.js # Matrix rain (commit-tethered)
|
||||
lightning.js # Lightning arcs between zones
|
||||
energy-beam.js # Energy beam (agent-count-tethered)
|
||||
rune-ring.js # Rune ring (portal-tethered)
|
||||
gravity-zones.js # Gravity anomaly zones
|
||||
shockwave.js # Shockwave, fireworks, merge flash
|
||||
terrain/
|
||||
island.js # Floating island + crystals
|
||||
clouds.js # Cloud layer (weather-tethered)
|
||||
stars.js # Star field + constellations (BTC-tethered)
|
||||
portals/
|
||||
portal-system.js # Portal creation, warp, health checks
|
||||
commit-banners.js # Floating commit banners
|
||||
narrative/
|
||||
bookshelves.js # Floating bookshelves (SOUL.md)
|
||||
oath.js # Oath display + enter/exit
|
||||
chat.js # Chat panel, speech bubbles, NPC dialog
|
||||
utils/
|
||||
perlin.js # Perlin noise generator
|
||||
geometry.js # Shared geometry helpers
|
||||
canvas-utils.js # Canvas texture creation helpers
|
||||
```
|
||||
|
||||
No build step. Served as static files. Import maps in `index.html` handle Three.js resolution.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **ES modules only** — no CommonJS, no bundler
|
||||
- **Modular architecture** — all logic in `modules/`. app.js is the orchestrator and should almost never change.
|
||||
- **Module contract** — every module exports `init(scene, state, theme)` and `update(elapsed, delta)`. Optional: `dispose()`
|
||||
- **Single animation clock** — one `requestAnimationFrame` in `ticker.js`. No module may call RAF directly. All subscribe to the ticker.
|
||||
- **Theme is law** — all colors, fonts, line weights come from `NEXUS.theme` in `theme.js`. No inline hex codes, no hardcoded font strings.
|
||||
- **Data flows through state** — data modules write to `state.js`, visual modules read from it. No `fetch()` outside `data/` modules.
|
||||
- **Conventional commits**: `feat:`, `fix:`, `refactor:`, `test:`, `chore:`
|
||||
- **Branch naming**: `claude/issue-{N}` (e.g. `claude/issue-5`)
|
||||
- **One PR at a time** — wait for merge-bot before opening the next
|
||||
- **Atomic PRs** — target <150 lines changed per PR. Commit by concern: data, logic, or visuals. If a change needs >200 lines, split into sequential PRs.
|
||||
- **No new code in app.js** — new features go in a new module or extend an existing module. The only reason to touch app.js is to add an import line for a new module.
|
||||
|
||||
## Validation (merge-bot checks)
|
||||
|
||||
The `nexus-merge-bot.sh` validates PRs before auto-merge:
|
||||
|
||||
1. HTML validation — `index.html` must be valid HTML
|
||||
2. JS syntax — `node --check app.js` must pass
|
||||
3. JSON validation — any `.json` files must parse
|
||||
4. File size budget — JS files must be < 500 KB
|
||||
|
||||
**Always run `node --check app.js` before committing.**
|
||||
|
||||
## Sequential Build Order — Nexus v1
|
||||
|
||||
Issues must be addressed one at a time. Only one PR open at a time.
|
||||
|
||||
| # | Issue | Status |
|
||||
|---|-------|--------|
|
||||
| 1 | #4 — Three.js scene foundation (lighting, camera, navigation) | ✅ done |
|
||||
| 2 | #5 — Portal system — YAML-driven registry | pending |
|
||||
| 3 | #6 — Batcave terminal — workshop integration in 3D | pending |
|
||||
| 4 | #9 — Visitor presence — live count + Timmy greeting | pending |
|
||||
| 5 | #8 — Agent idle behaviors in 3D world | pending |
|
||||
| 6 | #10 — Kimi & Perplexity as visible workshop agents | pending |
|
||||
| 7 | #11 — Tower Log — narrative event feed | pending |
|
||||
| 8 | #12 — NIP-07 visitor identity in the workshop | pending |
|
||||
| 9 | #13 — Timmy Nostr identity, zap-out, vouching | pending |
|
||||
| 10 | #14 — PWA manifest + service worker | pending |
|
||||
| 11 | #15 — Edge intelligence — browser model + silent Nostr signing | pending |
|
||||
| 12 | #16 — Session power meter — 3D balance visualizer | pending |
|
||||
| 13 | #18 — Unified memory graph & sovereignty loop visualization | pending |
|
||||
|
||||
## Commit Discipline
|
||||
|
||||
**Every PR must focus on exactly ONE concern. No exceptions.**
|
||||
|
||||
### PR Size Limits
|
||||
|
||||
- **Target: <150 lines changed per PR.** This is the default ceiling.
|
||||
- **Hard limit: >200 lines → split into sequential PRs.** If your change exceeds 200 lines, stop and decompose it before opening a PR.
|
||||
- **One concern per PR**: data layer, logic, OR visuals — never mixed in a single PR.
|
||||
|
||||
### Commit by Function
|
||||
|
||||
Use the concern as a commit scope prefix:
|
||||
|
||||
| Concern | Example commit message |
|
||||
|---------|----------------------|
|
||||
| Data layer | `feat: data-provider for agent status` |
|
||||
| Visual / style | `style: neon-update on portal ring` |
|
||||
| Refactor | `refactor: extract ticker from app.js` |
|
||||
| Fix | `fix: portal health-check timeout` |
|
||||
| Process / docs | `chore: update CLAUDE.md commit rules` |
|
||||
|
||||
### Decomposition Rules
|
||||
|
||||
When a feature spans multiple concerns (e.g. new data + new visual):
|
||||
|
||||
1. Open a PR for the data module first. Wait for merge.
|
||||
2. Open a PR for the visual module that reads from state. Wait for merge.
|
||||
3. Never combine data + visual work in one PR.
|
||||
|
||||
### Exception: Modularization Epics
|
||||
|
||||
Large refactors tracked as a numbered epic (e.g. #409) may use one PR per *phase*, where each phase is a logical, atomic unit of the refactor. Phases must still target <150 lines where possible and must not mix unrelated concerns.
|
||||
|
||||
## PR Rules
|
||||
|
||||
- Base every PR on latest `main`
|
||||
- Squash merge only
|
||||
- **Do NOT merge manually** — merge-bot handles merges
|
||||
- If merge-bot comments "CONFLICT": rebase onto `main` and force-push your branch
|
||||
- Include `Fixes #N` or `Refs #N` in commit message
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
npx serve . -l 3000
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
## Gitea API
|
||||
|
||||
```
|
||||
Base URL: http://143.198.27.163:3000/api/v1
|
||||
Repo: Timmy_Foundation/the-nexus
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nexus Data Integrity Standard
|
||||
|
||||
**This is law. Every contributor — human or AI — must follow these rules. No exceptions.**
|
||||
|
||||
### Core Principle
|
||||
|
||||
Every visual element in the Nexus must be tethered to reality. Nothing displayed may present fabricated data as if it were live. If a system is offline, the Nexus shows it as offline. If data doesn't exist yet, the element shows an honest empty state. There are zero acceptable reasons to display mocked data in the Nexus.
|
||||
|
||||
### The Three Categories
|
||||
|
||||
Every visual element falls into exactly one category:
|
||||
|
||||
1. **REAL** — Connected to a live data source (API, file, computed value). Displays truthful, current information. Examples: commit heatmap from Gitea, weather from Open-Meteo, Bitcoin block height.
|
||||
|
||||
2. **HONEST-OFFLINE** — The system it represents doesn't exist yet or is currently unreachable. The element is visible but clearly shows its offline/empty/awaiting state. Dim colors, empty bars, "OFFLINE" or "AWAITING DEPLOYMENT" labels. No fake numbers. Examples: dual-brain panel before deployment, LoRA panel with no adapters trained.
|
||||
|
||||
3. **DATA-TETHERED AESTHETIC** — Visually beautiful and apparently decorative, but its behavior (speed, density, brightness, color, intensity) is driven by a real data stream. The connection doesn't need to be obvious to the viewer, but it must exist in code. Examples: matrix rain density driven by commit activity, star brightness pulsing on Bitcoin blocks, cloud layer density from weather data.
|
||||
|
||||
### Banned Practices
|
||||
|
||||
- **No hardcoded stubs presented as live data.** No `AGENT_STATUS_STUB`, no `LORA_STATUS_STUB`, no hardcoded scores. If the data source isn't ready, show an empty/offline state.
|
||||
- **No static JSON files pretending to be APIs.** Files like `api/status.json` with hardcoded agent statuses are lies. Either fetch from the real API or show the element as disconnected.
|
||||
- **No fictional artifacts.** Files like `lora-status.json` containing invented adapter names that don't exist must be deleted. The filesystem must not contain fiction.
|
||||
- **No untethered aesthetics.** Every moving, glowing, or animated element must be connected to at least one real data stream. Pure decoration with no data connection is not permitted. Constellation lines (structural) are the sole exception.
|
||||
- **No "online" status for unreachable services.** If a URL doesn't respond to a health check, it is offline. The Nexus does not lie about availability.
|
||||
|
||||
### PR Requirements (Mandatory)
|
||||
|
||||
Every PR to this repository must include:
|
||||
|
||||
1. **Data Integrity Audit** — A table in the PR description listing every visual element the PR touches, its category (REAL / HONEST-OFFLINE / DATA-TETHERED AESTHETIC), and the data source it connects to. Format:
|
||||
|
||||
```
|
||||
| Element | Category | Data Source |
|
||||
|---------|----------|-------------|
|
||||
| Agent Status Board | REAL | Gitea API /repos/.../commits |
|
||||
| Matrix Rain | DATA-TETHERED AESTHETIC | zoneIntensity (commit count) |
|
||||
| Dual-Brain Panel | HONEST-OFFLINE | Shows "AWAITING DEPLOYMENT" |
|
||||
```
|
||||
|
||||
2. **Test Plan** — Specific steps to verify that every changed element displays truthful data or an honest offline state. Include:
|
||||
- How to trigger each state (online, offline, empty, active)
|
||||
- What the element should look like in each state
|
||||
- How to confirm the data source is real (API endpoint, computed value, etc.)
|
||||
|
||||
3. **Verification Screenshot** — At least one screenshot or recording showing the before-and-after state of changed elements. The screenshot must demonstrate:
|
||||
- Elements displaying real data or honest offline states
|
||||
- No hardcoded stubs visible
|
||||
- Aesthetic elements visibly responding to their data tether
|
||||
|
||||
4. **Syntax Check** — `node --check app.js` must pass. (Existing rule, restated for completeness.)
|
||||
|
||||
A PR missing any of these four items must not be merged.
|
||||
|
||||
### Existing Element Registry
|
||||
|
||||
Canonical reference for every Nexus element and its required data source:
|
||||
|
||||
| # | Element | Category | Data Source | Status |
|
||||
|---|---------|----------|-------------|--------|
|
||||
| 1 | Commit Heatmap | REAL | Gitea commits API | ✅ Connected |
|
||||
| 2 | Weather System | REAL | Open-Meteo API | ✅ Connected |
|
||||
| 3 | Bitcoin Block Height | REAL | blockstream.info | ✅ Connected |
|
||||
| 4 | Commit Banners | REAL | Gitea commits API | ✅ Connected |
|
||||
| 5 | Floating Bookshelves / Oath | REAL | SOUL.md file | ✅ Connected |
|
||||
| 6 | Portal System | REAL + Health Check | portals.json + URL probe | ✅ Connected |
|
||||
| 7 | Dual-Brain Panel | HONEST-OFFLINE | — (system not deployed) | ✅ Honest |
|
||||
| 8 | Agent Status Board | REAL | Gitea API (commits + PRs) | ✅ Connected |
|
||||
| 9 | LoRA Panel | HONEST-OFFLINE | — (no adapters deployed) | ✅ Honest |
|
||||
| 10 | Sovereignty Meter | REAL (manual) | sovereignty-status.json + MANUAL label | ✅ Connected |
|
||||
| 11 | Matrix Rain | DATA-TETHERED AESTHETIC | zoneIntensity (commits) + commit hashes | ✅ Tethered |
|
||||
| 12 | Star Field | DATA-TETHERED AESTHETIC | Bitcoin block events (brightness pulse) | ✅ Tethered |
|
||||
| 13 | Constellation Lines | STRUCTURAL (exempt) | — | ✅ No change needed |
|
||||
| 14 | Crystal Formations | DATA-TETHERED AESTHETIC | totalActivity() | 🔍 Verify connection |
|
||||
| 15 | Cloud Layer | DATA-TETHERED AESTHETIC | Weather API (cloud_cover) | ✅ Tethered |
|
||||
| 16 | Rune Ring | DATA-TETHERED AESTHETIC | portals.json (count + status + colors) | ✅ Tethered |
|
||||
| 17 | Holographic Earth | DATA-TETHERED AESTHETIC | totalActivity() (rotation speed) | ✅ Tethered |
|
||||
| 18 | Energy Beam | DATA-TETHERED AESTHETIC | Active agent count | ✅ Tethered |
|
||||
| 19 | Gravity Anomaly Zones | DATA-TETHERED AESTHETIC | Portal positions + status | ✅ Tethered |
|
||||
| 20 | Brain Pulse Particles | HONEST-OFFLINE | — (dual-brain not deployed, particles OFF) | ✅ Honest |
|
||||
|
||||
When a new visual element is added, it must be added to this registry in the same PR.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Any agent or contributor that introduces mocked data, untethered aesthetics, or fake statuses into the Nexus is in violation of this standard. The merge-bot should reject PRs that lack the required audit table, test plan, or verification screenshot. This standard is permanent and retroactive — existing violations must be fixed, not grandfathered.
|
||||
62
CONTRIBUTING.md
Normal file
62
CONTRIBUTING.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Contributing to The Nexus
|
||||
|
||||
Thanks for contributing to Timmy's sovereign home. Please read this before opening a PR.
|
||||
|
||||
## Project Stack
|
||||
|
||||
- Vanilla JS ES modules, Three.js 0.183, no bundler
|
||||
- Static files — no build step
|
||||
- Import maps in `index.html` handle Three.js resolution
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
index.html # Entry point: HUD, chat panel, loading screen
|
||||
style.css # Design system: dark space theme, holographic panels
|
||||
app.js # Three.js scene, shaders, controls, game loop (~all logic)
|
||||
```
|
||||
|
||||
Keep logic in `app.js`. Don't split without a good reason.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **ES modules only** — no CommonJS, no bundler imports
|
||||
- **Color palette** — defined in `NEXUS.colors` at the top of `app.js`; use it, don't hardcode colors
|
||||
- **Conventional commits**: `feat:`, `fix:`, `refactor:`, `test:`, `chore:`
|
||||
- **Branch naming**: `claude/issue-{N}` for agent work, `yourname/issue-{N}` for humans
|
||||
- **One PR at a time** — wait for the merge-bot before opening the next
|
||||
|
||||
## Before You Submit
|
||||
|
||||
1. Run the JS syntax check:
|
||||
```bash
|
||||
node --check app.js
|
||||
```
|
||||
2. Validate `index.html` — it must be valid HTML
|
||||
3. Keep JS files under 500 KB
|
||||
4. Any `.json` files you add must parse cleanly
|
||||
|
||||
These are the same checks the merge-bot runs. Failing them will block your PR.
|
||||
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
npx serve . -l 3000
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
## PR Rules
|
||||
|
||||
- Base your branch on latest `main`
|
||||
- Squash merge only
|
||||
- **Do not merge manually** — the merge-bot handles merges
|
||||
- If merge-bot comments "CONFLICT": rebase onto `main` and force-push your branch
|
||||
- Include `Fixes #N` or `Refs #N` in your commit message
|
||||
|
||||
## Issue Ordering
|
||||
|
||||
The Nexus v1 issues are sequential — each builds on the last. Check the build order in [CLAUDE.md](CLAUDE.md) before starting work to avoid conflicts.
|
||||
|
||||
## Questions
|
||||
|
||||
Open an issue or reach out via the Timmy Terminal chat inside the Nexus.
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,6 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN npm install -g serve
|
||||
EXPOSE 3000
|
||||
CMD ["serve", ".", "-l", "3000", "--no-clipboard"]
|
||||
FROM nginx:alpine
|
||||
COPY . /usr/share/nginx/html
|
||||
RUN rm -f /usr/share/nginx/html/Dockerfile \
|
||||
/usr/share/nginx/html/docker-compose.yml \
|
||||
/usr/share/nginx/html/deploy.sh
|
||||
EXPOSE 80
|
||||
|
||||
9
api/status.json
Normal file
9
api/status.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"agents": [
|
||||
{ "name": "claude", "status": "working", "issue": "Live agent status board (#199)", "prs_today": 3 },
|
||||
{ "name": "gemini", "status": "idle", "issue": null, "prs_today": 1 },
|
||||
{ "name": "kimi", "status": "working", "issue": "Portal system YAML registry (#5)", "prs_today": 2 },
|
||||
{ "name": "groq", "status": "idle", "issue": null, "prs_today": 0 },
|
||||
{ "name": "grok", "status": "dead", "issue": null, "prs_today": 0 }
|
||||
]
|
||||
}
|
||||
66
apply_cyberpunk.py
Normal file
66
apply_cyberpunk.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import re
|
||||
import os
|
||||
|
||||
# 1. Update style.css
|
||||
with open('style.css', 'a') as f:
|
||||
f.write('''
|
||||
/* === CRT / CYBERPUNK OVERLAY === */
|
||||
.crt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%),
|
||||
linear-gradient(90deg, rgba(255, 0, 0, 0.04), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.04));
|
||||
background-size: 100% 4px, 4px 100%;
|
||||
animation: flicker 0.15s infinite;
|
||||
box-shadow: inset 0 0 100px rgba(0,0,0,0.9);
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0.95; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.98; }
|
||||
}
|
||||
|
||||
.crt-overlay::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(18, 16, 16, 0.1);
|
||||
opacity: 0;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
animation: crt-pulse 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes crt-pulse {
|
||||
0% { opacity: 0.05; }
|
||||
50% { opacity: 0.15; }
|
||||
100% { opacity: 0.05; }
|
||||
}
|
||||
''')
|
||||
|
||||
# 2. Update index.html
|
||||
if os.path.exists('index.html'):
|
||||
with open('index.html', 'r') as f:
|
||||
html = f.read()
|
||||
if '<div class="crt-overlay"></div>' not in html:
|
||||
html = html.replace('</body>', ' <div class="crt-overlay"></div>\n</body>')
|
||||
with open('index.html', 'w') as f:
|
||||
f.write(html)
|
||||
|
||||
# 3. Update app.js UnrealBloomPass
|
||||
if os.path.exists('app.js'):
|
||||
with open('app.js', 'r') as f:
|
||||
js = f.read()
|
||||
new_js = re.sub(r'UnrealBloomPass\([^,]+,\s*0\.6\s*,', r'UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5,', js)
|
||||
with open('app.js', 'w') as f:
|
||||
f.write(new_js)
|
||||
|
||||
print("Applied Cyberpunk Overhaul!")
|
||||
44
deploy.sh
44
deploy.sh
@@ -1,20 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# ◈ Nexus — quick deploy helper
|
||||
# Usage:
|
||||
# ./deploy.sh # deploy main to production (port 4200)
|
||||
# ./deploy.sh staging # deploy current branch to staging (port 4201)
|
||||
|
||||
# deploy.sh — pull latest main and restart the Nexus
|
||||
#
|
||||
# Usage (on the VPS):
|
||||
# ./deploy.sh — deploy nexus-main (port 4200)
|
||||
# ./deploy.sh staging — deploy nexus-staging (port 4201)
|
||||
#
|
||||
# Expected layout on VPS:
|
||||
# /opt/the-nexus/ ← git clone of this repo (git remote = origin, branch = main)
|
||||
# nginx site config ← /etc/nginx/sites-enabled/the-nexus
|
||||
set -euo pipefail
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
MODE=${1:-production}
|
||||
SERVICE="${1:-nexus-main}"
|
||||
|
||||
echo "◈ Nexus deploy — branch: $BRANCH mode: $MODE"
|
||||
case "$SERVICE" in
|
||||
staging) SERVICE="nexus-staging" ;;
|
||||
main) SERVICE="nexus-main" ;;
|
||||
esac
|
||||
|
||||
if [ "$MODE" = "staging" ]; then
|
||||
docker compose --profile staging up -d --build nexus-staging
|
||||
echo "✓ Staging live at http://localhost:4201 (branch: $BRANCH)"
|
||||
else
|
||||
docker compose up -d --build nexus
|
||||
echo "✓ Production live at http://localhost:4200"
|
||||
fi
|
||||
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "==> Pulling latest main …"
|
||||
git -C "$REPO_DIR" fetch origin
|
||||
git -C "$REPO_DIR" checkout main
|
||||
git -C "$REPO_DIR" reset --hard origin/main
|
||||
|
||||
echo "==> Building and restarting $SERVICE …"
|
||||
docker compose -f "$REPO_DIR/docker-compose.yml" build "$SERVICE"
|
||||
docker compose -f "$REPO_DIR/docker-compose.yml" up -d --force-recreate "$SERVICE"
|
||||
|
||||
echo "==> Reloading nginx …"
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
echo "==> Done. $SERVICE is live."
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
version: '3.9'
|
||||
|
||||
# ◈ The Nexus — staging deployments
|
||||
#
|
||||
# Production (main):
|
||||
# docker compose up -d nexus
|
||||
# → http://<host>:4200
|
||||
#
|
||||
# Branch staging:
|
||||
# BRANCH=my-feature docker compose up -d nexus-staging
|
||||
# → http://<host>:4201
|
||||
#
|
||||
# To update production after a git pull:
|
||||
# docker compose up -d --build nexus
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
nexus:
|
||||
nexus-main:
|
||||
build: .
|
||||
container_name: nexus-main
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4200:3000"
|
||||
- "4200:80"
|
||||
volumes:
|
||||
- .:/usr/share/nginx/html:ro
|
||||
labels:
|
||||
- "nexus.branch=main"
|
||||
- "deployment=main"
|
||||
|
||||
nexus-staging:
|
||||
build:
|
||||
context: .
|
||||
build: .
|
||||
container_name: nexus-staging
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4201:3000"
|
||||
- "4201:80"
|
||||
volumes:
|
||||
- .:/usr/share/nginx/html:ro
|
||||
labels:
|
||||
- "nexus.branch=staging"
|
||||
profiles:
|
||||
- staging
|
||||
- "deployment=staging"
|
||||
|
||||
302
heartbeat.html
Normal file
302
heartbeat.html
Normal file
@@ -0,0 +1,302 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<title>Nexus Heartbeat</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #0a0a0a;
|
||||
color: #00ff00;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 375px; /* Mobile screen width */
|
||||
padding: 10px;
|
||||
border: 1px solid #006600;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 0, 0.5);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
color: #00ffff;
|
||||
text-align: center;
|
||||
font-size: 1.5em;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
text-shadow: 0 0 5px rgba(0, 255, 255, 0.7);
|
||||
}
|
||||
.status-section {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.status-section h2 {
|
||||
color: #00ffcc;
|
||||
font-size: 1.2em;
|
||||
border-bottom: 1px dashed #003300;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.status-label {
|
||||
color: #00ccff;
|
||||
flex-shrink: 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.status-value {
|
||||
color: #00ff00;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
.agent-status.working { color: #00ff00; }
|
||||
.agent-status.idle { color: #ffff00; }
|
||||
.agent-status.dead { color: #ff0000; }
|
||||
|
||||
.last-updated {
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: #009900;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>NEXUS HEARTBEAT</h1>
|
||||
|
||||
<div class="status-section">
|
||||
<h2>SOVEREIGNTY STATUS</h2>
|
||||
<div class="status-item">
|
||||
<span class="status-label">SCORE:</span>
|
||||
<span class="status-value" id="sovereignty-score">LOADING...</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">LABEL:</span>
|
||||
<span class="status-value" id="sovereignty-label">LOADING...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<h2>AGENT STATUSES</h2>
|
||||
<div id="agent-statuses">
|
||||
<div class="status-item"><span class="status-label">LOADING...</span><span class="status-value"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<h2>LAST COMMITS</h2>
|
||||
<div id="last-commits">
|
||||
<div class="status-item"><span class="status-label">LOADING...</span><span class="status-value"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<h2>ENVIRONMENTALS</h2>
|
||||
<div class="status-item">
|
||||
<span class="status-label">WEATHER:</span>
|
||||
<span class="status-value" id="weather">UNKNOWN</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">BTC BLOCK:</span>
|
||||
<span class="status-value" id="btc-block">UNKNOWN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="last-updated" id="last-updated">
|
||||
Last Updated: NEVER
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const GITEA_API_URL = 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus';
|
||||
const GITEA_TOKEN = 'f7bcdaf878d479ad7747873ff6739a9bb89e3f80'; // Updated token
|
||||
const SOVEREIGNTY_STATUS_FILE = './sovereignty-status.json';
|
||||
|
||||
const WEATHER_LAT = 43.2897; // Lempster NH
|
||||
const WEATHER_LON = -72.1479; // Lempster NH
|
||||
const BTC_API_URL = 'https://blockstream.info/api/blocks/tip/height';
|
||||
// For agent status, we'll derive from Gitea commits. This is a placeholder list of expected agents.
|
||||
const GITEA_USERS = ['perplexity', 'timmy', 'gemini']; // Example users, needs to be derived dynamically or configured
|
||||
|
||||
function weatherCodeToLabel(code) {
|
||||
// Simplified mapping from Open-Meteo WMO codes to labels
|
||||
if (code >= 0 && code <= 1) return { condition: 'Clear', icon: '☀️' };
|
||||
if (code >= 2 && code <= 3) return { condition: 'Partly Cloudy', icon: '🌤️' };
|
||||
if (code >= 45 && code <= 48) return { condition: 'Foggy', icon: '🌫️' };
|
||||
if (code >= 51 && code <= 55) return { condition: 'Drizzle', icon: '🌧️' };
|
||||
if (code >= 61 && code <= 65) return { condition: 'Rain', icon: '☔' };
|
||||
if (code >= 71 && code <= 75) return { condition: 'Snow', icon: '🌨️' };
|
||||
if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' };
|
||||
return { condition: 'Unknown', icon: '❓' };
|
||||
}
|
||||
|
||||
|
||||
async function fetchSovereigntyStatus() {
|
||||
try {
|
||||
const response = await fetch(SOVEREIGNTY_STATUS_FILE);
|
||||
const data = await response.json();
|
||||
document.getElementById('sovereignty-score').textContent = data.score + '%';
|
||||
document.getElementById('sovereignty-label').textContent = data.label.toUpperCase();
|
||||
} catch (error) {
|
||||
console.error('Error fetching sovereignty status:', error);
|
||||
document.getElementById('sovereignty-score').textContent = 'ERROR';
|
||||
document.getElementById('sovereignty-label').textContent = 'ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgentStatuses() {
|
||||
try {
|
||||
const response = await fetch(GITEA_API_URL + '/commits?limit=50', {
|
||||
headers: {
|
||||
'Authorization': `token ${GITEA_TOKEN}`
|
||||
}
|
||||
});
|
||||
const commits = await response.json();
|
||||
const agentStatusesDiv = document.getElementById('agent-statuses');
|
||||
agentStatusesDiv.innerHTML = ''; // Clear previous statuses
|
||||
|
||||
const agentActivity = {};
|
||||
const now = Date.now();
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Initialize all known agents as idle
|
||||
GITEA_USERS.forEach(user => {
|
||||
agentActivity[user.toLowerCase()] = { status: 'IDLE', lastCommit: 0 };
|
||||
});
|
||||
|
||||
commits.forEach(commit => {
|
||||
const authorName = commit.commit.author.name.toLowerCase();
|
||||
const commitTime = new Date(commit.commit.author.date).getTime();
|
||||
|
||||
if (GITEA_USERS.includes(authorName)) {
|
||||
if (commitTime > (now - twentyFourHours)) {
|
||||
// If commit within last 24 hours, agent is working
|
||||
agentActivity[authorName].status = 'WORKING';
|
||||
}
|
||||
if (commitTime > agentActivity[authorName].lastCommit) {
|
||||
agentActivity[authorName].lastCommit = commitTime;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(agentActivity).forEach(agentName => {
|
||||
const agent = agentActivity[agentName];
|
||||
const agentItem = document.createElement('div');
|
||||
agentItem.className = 'status-item';
|
||||
const statusClass = agent.status.toLowerCase();
|
||||
agentItem.innerHTML = `
|
||||
<span class="status-label">${agentName.toUpperCase()}:</span>
|
||||
<span class="status-value agent-status ${statusClass}">${agent.status}</span>
|
||||
`;
|
||||
agentStatusesDiv.appendChild(agentItem);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching agent statuses:', error);
|
||||
const agentStatusesDiv = document.getElementById('agent-statuses');
|
||||
agentStatusesDiv.innerHTML = '<div class="status-item"><span class="status-label">AGENTS:</span><span class="status-value agent-status dead">ERROR</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLastCommits() {
|
||||
try {
|
||||
const response = await fetch(GITEA_API_URL + '/commits?limit=5', { // Limit to 5 for lightweight page
|
||||
headers: {
|
||||
'Authorization': `token ${GITEA_TOKEN}`
|
||||
}
|
||||
});
|
||||
const commits = await response.json();
|
||||
const lastCommitsDiv = document.getElementById('last-commits');
|
||||
lastCommitsDiv.innerHTML = ''; // Clear previous commits
|
||||
|
||||
if (commits.length === 0) {
|
||||
lastCommitsDiv.innerHTML = '<div class="status-item"><span class="status-label">NO COMMITS</span><span class="status-value"></span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
commits.slice(0, 5).forEach(commit => { // Display top 5 recent commits
|
||||
const commitItem = document.createElement('div');
|
||||
commitItem.className = 'status-item';
|
||||
const author = commit.commit.author.name;
|
||||
const date = new Date(commit.commit.author.date).toLocaleString();
|
||||
const message = commit.commit.message.split('
|
||||
')[0]; // First line of commit message
|
||||
|
||||
commitItem.innerHTML = `
|
||||
<span class="status-label">${author}:</span>
|
||||
<span class="status-value" title="${message}">${date}</span>
|
||||
`;
|
||||
lastCommitsDiv.appendChild(commitItem);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching last commits:', error);
|
||||
const lastCommitsDiv = document.getElementById('last-commits');
|
||||
lastCommitsDiv.innerHTML = '<div class="status-item"><span class="status-label">COMMITS:</span><span class="status-value agent-status dead">ERROR</span></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWeather() {
|
||||
try {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code&temperature_unit=fahrenheit&forecast_days=1`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) throw new Error('Weather fetch failed');
|
||||
|
||||
const temp = data.current.temperature_2m;
|
||||
const code = data.current.weather_code;
|
||||
const { condition } = weatherCodeToLabel(code);
|
||||
|
||||
document.getElementById('weather').textContent = `${temp}°F, ${condition}`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching weather:', error);
|
||||
document.getElementById('weather').textContent = 'ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBtcBlock() {
|
||||
try {
|
||||
const response = await fetch(BTC_API_URL);
|
||||
const blockHeight = await response.text();
|
||||
document.getElementById('btc-block').textContent = blockHeight;
|
||||
} catch (error) {
|
||||
console.error('Error fetching BTC block:', error);
|
||||
document.getElementById('btc-block').textContent = 'ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimestamp() {
|
||||
document.getElementById('last-updated').textContent = 'Last Updated: ' + new Date().toLocaleString();
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
await fetchSovereigntyStatus();
|
||||
await fetchAgentStatuses();
|
||||
await fetchLastCommits();
|
||||
await fetchWeather();
|
||||
await fetchBtcBlock();
|
||||
updateTimestamp();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
updateStatus();
|
||||
|
||||
// Auto-refresh every 60 seconds (already set by meta tag, but this ensures data fetch)
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
256
index.html
256
index.html
@@ -1,169 +1,109 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!--
|
||||
______ __
|
||||
/ ____/___ ____ ___ ____ __ __/ /____ _____
|
||||
/ / / __ \/ __ `__ \/ __ \/ / / / __/ _ \/ ___/
|
||||
/ /___/ /_/ / / / / / / /_/ / /_/ / /_/ __/ /
|
||||
\____/\____/_/ /_/ /_/ .___/\__,_/\__/\___/_/
|
||||
/_/
|
||||
Created with Perplexity Computer
|
||||
https://www.perplexity.ai/computer
|
||||
-->
|
||||
<meta name="generator" content="Perplexity Computer">
|
||||
<meta name="author" content="Perplexity Computer">
|
||||
<meta property="og:see_also" content="https://www.perplexity.ai/computer">
|
||||
<link rel="author" href="https://www.perplexity.ai/computer">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Nexus — Timmy's Sovereign Home</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.183.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.183.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Timmy's Nexus</title>
|
||||
<meta name="description" content="A sovereign 3D world">
|
||||
<meta property="og:title" content="Timmy's Nexus">
|
||||
<meta property="og:description" content="A sovereign 3D world">
|
||||
<meta property="og:image" content="https://example.com/og-image.png">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Timmy's Nexus">
|
||||
<meta name="twitter:description" content="A sovereign 3D world">
|
||||
<meta name="twitter:image" content="https://example.com/og-image.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.183.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.183.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loading Screen -->
|
||||
<div id="loading-screen">
|
||||
<div class="loader-content">
|
||||
<div class="loader-sigil">
|
||||
<svg viewBox="0 0 120 120" width="120" height="120">
|
||||
<defs>
|
||||
<linearGradient id="sigil-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#4af0c0"/>
|
||||
<stop offset="100%" stop-color="#7b5cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="60" cy="60" r="55" fill="none" stroke="url(#sigil-grad)" stroke-width="1.5" opacity="0.4"/>
|
||||
<circle cx="60" cy="60" r="45" fill="none" stroke="url(#sigil-grad)" stroke-width="1" opacity="0.3">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="360 60 60" dur="8s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<polygon points="60,15 95,80 25,80" fill="none" stroke="#4af0c0" stroke-width="1.5" opacity="0.6">
|
||||
<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="-360 60 60" dur="12s" repeatCount="indefinite"/>
|
||||
</polygon>
|
||||
<circle cx="60" cy="60" r="8" fill="#4af0c0" opacity="0.8">
|
||||
<animate attributeName="r" values="6;10;6" dur="2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="loader-title">THE NEXUS</h1>
|
||||
<p class="loader-subtitle">Initializing Sovereign Space...</p>
|
||||
<div class="loader-bar"><div class="loader-fill" id="load-progress"></div></div>
|
||||
<!-- Top Right: Audio Toggle -->
|
||||
<div id="audio-control" class="hud-controls" style="position: absolute; top: 8px; right: 8px;">
|
||||
<button id="audio-toggle" class="chat-toggle-btn" aria-label="Toggle ambient sound" style="background-color: var(--color-primary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
|
||||
🔊
|
||||
</button>
|
||||
<button id="debug-toggle" class="chat-toggle-btn" aria-label="Toggle debug mode" style="background-color: var(--color-secondary); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
|
||||
🔍
|
||||
</button>
|
||||
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
|
||||
📥
|
||||
</button>
|
||||
<button id="podcast-toggle" class="chat-toggle-btn" aria-label="Start podcast of SOUL.md" title="Play SOUL.md as audio" style="margin-left: 8px; background-color: var(--color-accent); color: var(--color-bg); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
|
||||
🎧
|
||||
</button>
|
||||
<button id="soul-toggle" class="chat-toggle-btn" aria-label="Read SOUL.md aloud" title="Read SOUL.md as dramatic audio" style="margin-left: 8px; background-color: var(--color-secondary); color: var(--color-text); padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer;">
|
||||
📜
|
||||
</button>
|
||||
<div id="podcast-error" style="display: none; position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(255, 0, 0, 0.8); color: white; padding: 6px 12px; border-radius: 4px; font-size: 12px;"></div>
|
||||
<div id="podcast-error" style="display: none; position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%); background: rgba(255, 0, 0, 0.8); color: white; padding: 6px 12px; border-radius: 4px; font-size: 12px;"></div>
|
||||
<button id="timelapse-btn" class="chat-toggle-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today's activity in 30s [L]">
|
||||
⏩
|
||||
</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
|
||||
<div id="overview-indicator">
|
||||
<span>MAP VIEW</span>
|
||||
<span class="overview-hint">[Tab] to exit</span>
|
||||
</div>
|
||||
|
||||
<div id="photo-indicator">
|
||||
<span>PHOTO MODE</span>
|
||||
<span class="photo-hint">[P] exit | [[] focus- []] focus+ focus: <span id="photo-focus">5.0</span></span>
|
||||
</div>
|
||||
|
||||
<div id="sovereignty-msg">⚡ SOVEREIGNTY ⚡</div>
|
||||
|
||||
<div id="block-height-display">
|
||||
<span class="block-height-label">⛏ BLOCK</span>
|
||||
<span id="block-height-value">—</span>
|
||||
</div>
|
||||
|
||||
<div id="zoom-indicator">
|
||||
<span>ZOOMED: <span id="zoom-label">Object</span></span>
|
||||
<span class="zoom-hint">[Esc] or double-click to exit</span>
|
||||
</div>
|
||||
|
||||
<div id="weather-hud">
|
||||
<span id="weather-icon">⛅</span>
|
||||
<span id="weather-temp">--°F</span>
|
||||
<span id="weather-desc">Lempster NH</span>
|
||||
</div>
|
||||
|
||||
<!-- TIME-LAPSE MODE indicator -->
|
||||
<div id="timelapse-indicator" aria-live="polite" aria-label="Time-lapse mode active">
|
||||
<span class="timelapse-label">⏩ TIME-LAPSE</span>
|
||||
<span id="timelapse-clock">00:00</span>
|
||||
<div class="timelapse-track"><div id="timelapse-bar"></div></div>
|
||||
<span class="timelapse-hint">[L] or [Esc] to stop</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="app.js"></script>
|
||||
<div id="loading" style="position: fixed; top: 0; left: 0; right: 0; height: 4px; background: #222; z-index: 1000;">
|
||||
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
|
||||
</div>
|
||||
<div class="crt-overlay"></div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div id="hud" class="game-ui" style="display:none;">
|
||||
<!-- Top Left: Debug -->
|
||||
<div id="debug-overlay" class="hud-debug"></div>
|
||||
|
||||
<!-- Top Center: Location -->
|
||||
<div class="hud-location">
|
||||
<span class="hud-location-icon">◈</span>
|
||||
<span id="hud-location-text">The Nexus</span>
|
||||
</div>
|
||||
|
||||
<!-- Bottom: Chat Interface -->
|
||||
<div id="chat-panel" class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<span class="chat-status-dot"></span>
|
||||
<span>Timmy Terminal</span>
|
||||
<button id="chat-toggle" class="chat-toggle-btn" aria-label="Toggle chat">▼</button>
|
||||
</div>
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-msg chat-msg-system">
|
||||
<span class="chat-msg-prefix">[NEXUS]</span> Sovereign space initialized. Timmy is observing.
|
||||
</div>
|
||||
<div class="chat-msg chat-msg-timmy">
|
||||
<span class="chat-msg-prefix">[TIMMY]</span> Welcome to the Nexus, Alexander. All systems nominal.
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
<input type="text" id="chat-input" class="chat-input" placeholder="Speak to Timmy..." autocomplete="off">
|
||||
<button id="chat-send" class="chat-send-btn" aria-label="Send message">→</button>
|
||||
<!-- THE OATH overlay -->
|
||||
<div id="oath-overlay" aria-live="polite" aria-label="The Oath reading">
|
||||
<div id="oath-inner">
|
||||
<div id="oath-title">THE OATH</div>
|
||||
<div id="oath-text"></div>
|
||||
<div id="oath-hint">[O] or [Esc] to close</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimap / Controls hint -->
|
||||
<div class="hud-controls">
|
||||
<span>WASD</span> move <span>Mouse</span> look <span>Enter</span> chat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Click to Enter -->
|
||||
<div id="enter-prompt" style="display:none;">
|
||||
<div class="enter-content">
|
||||
<h2>Enter The Nexus</h2>
|
||||
<p>Click anywhere to begin</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="nexus-canvas"></canvas>
|
||||
|
||||
<footer class="nexus-footer">
|
||||
<a href="https://www.perplexity.ai/computer" target="_blank" rel="noopener noreferrer">
|
||||
Created with Perplexity Computer
|
||||
</a>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
|
||||
<!-- Live Reload: polls Gitea for new commits, refreshes when main advances -->
|
||||
<div id="update-banner" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;
|
||||
background:linear-gradient(90deg,#4af0c0,#7b5cff);color:#050510;
|
||||
font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:600;
|
||||
text-align:center;padding:8px;cursor:pointer;letter-spacing:0.05em;"
|
||||
onclick="location.reload()">
|
||||
◈ NEW VERSION DEPLOYED — click to reload
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const GITEA_API = 'http://143.198.27.163:3000/api/v1';
|
||||
const REPO = 'Timmy_Foundation/the-nexus';
|
||||
const BRANCH = 'main';
|
||||
const INTERVAL = 30000; // 30s
|
||||
|
||||
let knownSha = null;
|
||||
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${GITEA_API}/repos/${REPO}/commits?sha=${BRANCH}&limit=1`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const commits = await res.json();
|
||||
if (!commits || !commits[0]) return;
|
||||
const sha = commits[0].sha;
|
||||
if (knownSha === null) {
|
||||
knownSha = sha;
|
||||
return;
|
||||
}
|
||||
if (sha !== knownSha) {
|
||||
document.getElementById('update-banner').style.display = 'block';
|
||||
// Auto-reload after 5s
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
}
|
||||
} catch (_) { /* offline or network error — skip */ }
|
||||
}
|
||||
|
||||
// Start polling once page is loaded
|
||||
window.addEventListener('load', () => {
|
||||
checkForUpdates();
|
||||
setInterval(checkForUpdates, INTERVAL);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
20
manifest.json
Normal file
20
manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "Timmy's Nexus",
|
||||
"short_name": "Nexus",
|
||||
"start_url": "/",
|
||||
"display": "fullscreen",
|
||||
"background_color": "#050510",
|
||||
"theme_color": "#050510",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/t-logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/t-logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
35
modules/core/state.js
Normal file
35
modules/core/state.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// modules/core/state.js — Shared reactive data bus
|
||||
// Data modules write here; visual modules read from here.
|
||||
// No module may call fetch() except those under modules/data/.
|
||||
|
||||
export const state = {
|
||||
// Commit heatmap (written by data/gitea.js)
|
||||
zoneIntensity: {}, // { zoneName: [0..1], ... }
|
||||
commits: [], // raw commit objects (last N)
|
||||
commitHashes: [], // short hashes for matrix rain
|
||||
|
||||
// Agent status (written by data/gitea.js)
|
||||
agentStatus: null, // { agents: Array<AgentRecord> } | null
|
||||
activeAgentCount: 0, // count of agents with status === 'working'
|
||||
|
||||
// Weather (written by data/weather.js)
|
||||
weather: null, // { cloud_cover, precipitation, ... } | null
|
||||
|
||||
// Bitcoin (written by data/bitcoin.js)
|
||||
blockHeight: 0,
|
||||
lastBlockHeight: 0,
|
||||
newBlockDetected: false,
|
||||
starPulseIntensity: 0,
|
||||
|
||||
// Portal / sovereignty / SOUL (written by data/loaders.js)
|
||||
portals: [], // portal descriptor objects
|
||||
sovereignty: null, // { score, label, assessment_type } | null
|
||||
soulMd: '', // raw SOUL.md text
|
||||
|
||||
// Computed helpers
|
||||
totalActivity() {
|
||||
const vals = Object.values(this.zoneIntensity);
|
||||
if (vals.length === 0) return 0;
|
||||
return vals.reduce((s, v) => s + v, 0) / vals.length;
|
||||
},
|
||||
};
|
||||
56
modules/core/theme.js
Normal file
56
modules/core/theme.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// modules/core/theme.js — Visual design system for the Nexus
|
||||
// All colors, fonts, line weights, and glow params live here.
|
||||
// No module may use inline hex codes — all visual constants come from NEXUS.theme.
|
||||
|
||||
export const NEXUS = {
|
||||
theme: {
|
||||
// Core palette
|
||||
bg: 0x000008,
|
||||
accent: 0x4488ff,
|
||||
accentStr: '#4488ff',
|
||||
starCore: 0xffffff,
|
||||
starDim: 0x8899cc,
|
||||
constellationLine: 0x334488,
|
||||
|
||||
// Agent status colors (hex strings for canvas, hex numbers for THREE)
|
||||
agentWorking: '#00ff88',
|
||||
agentWorkingHex: 0x00ff88,
|
||||
agentIdle: '#4488ff',
|
||||
agentIdleHex: 0x4488ff,
|
||||
agentDormant: '#334466',
|
||||
agentDormantHex: 0x334466,
|
||||
agentDead: '#ff4444',
|
||||
agentDeadHex: 0xff4444,
|
||||
|
||||
// Sovereignty meter colors
|
||||
sovereignHigh: '#00ff88', // score >= 80
|
||||
sovereignHighHex: 0x00ff88,
|
||||
sovereignMid: '#ffcc00', // score >= 40
|
||||
sovereignMidHex: 0xffcc00,
|
||||
sovereignLow: '#ff4444', // score < 40
|
||||
sovereignLowHex: 0xff4444,
|
||||
|
||||
// LoRA / training panel
|
||||
loraAccent: '#cc44ff',
|
||||
loraAccentHex: 0xcc44ff,
|
||||
loraActive: '#00ff88',
|
||||
loraInactive: '#334466',
|
||||
|
||||
// Earth
|
||||
earthOcean: 0x003d99,
|
||||
earthLand: 0x1a5c2a,
|
||||
earthAtm: 0x1144cc,
|
||||
earthGlow: 0x4488ff,
|
||||
|
||||
// Panel chrome
|
||||
panelBg: 'rgba(0, 6, 20, 0.90)',
|
||||
panelBorder: '#4488ff',
|
||||
panelBorderFaint: '#1a3a6a',
|
||||
panelText: '#ccd6f6',
|
||||
panelDim: '#556688',
|
||||
panelVeryDim: '#334466',
|
||||
|
||||
// Typography
|
||||
fontMono: '"Courier New", monospace',
|
||||
},
|
||||
};
|
||||
46
modules/core/ticker.js
Normal file
46
modules/core/ticker.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// modules/core/ticker.js — Global Animation Clock
|
||||
// Single requestAnimationFrame loop. All modules subscribe here.
|
||||
// No module may call requestAnimationFrame directly.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const _clock = new THREE.Clock();
|
||||
const _subscribers = [];
|
||||
|
||||
let _running = false;
|
||||
let _elapsed = 0;
|
||||
|
||||
/**
|
||||
* Subscribe a callback to the animation loop.
|
||||
* @param {(elapsed: number, delta: number) => void} fn
|
||||
*/
|
||||
export function subscribe(fn) {
|
||||
_subscribers.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a callback from the animation loop.
|
||||
* @param {(elapsed: number, delta: number) => void} fn
|
||||
*/
|
||||
export function unsubscribe(fn) {
|
||||
const idx = _subscribers.indexOf(fn);
|
||||
if (idx !== -1) _subscribers.splice(idx, 1);
|
||||
}
|
||||
|
||||
/** Start the animation loop. Called once by app.js after all modules are init'd. */
|
||||
export function start() {
|
||||
if (_running) return;
|
||||
_running = true;
|
||||
_tick();
|
||||
}
|
||||
|
||||
function _tick() {
|
||||
if (!_running) return;
|
||||
requestAnimationFrame(_tick);
|
||||
const delta = _clock.getDelta();
|
||||
_elapsed += delta;
|
||||
for (const fn of _subscribers) fn(_elapsed, delta);
|
||||
}
|
||||
|
||||
/** Current elapsed time in seconds (read-only). */
|
||||
export function elapsed() { return _elapsed; }
|
||||
56
modules/effects/energy-beam.js
Normal file
56
modules/effects/energy-beam.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* energy-beam.js — Vertical energy beam above the Batcave terminal
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: state.activeAgentCount (0 = faint, 3+ = full intensity)
|
||||
*
|
||||
* A glowing cyan cylinder rising from the Batcave area.
|
||||
* Intensity and pulse amplitude are driven by the number of active agents.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BEAM_RADIUS = 0.2;
|
||||
const BEAM_HEIGHT = 50;
|
||||
const BEAM_X = -10;
|
||||
const BEAM_Y = 0;
|
||||
const BEAM_Z = -10;
|
||||
|
||||
let _state = null;
|
||||
let _beamMaterial = null;
|
||||
let _pulse = 0;
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} scene
|
||||
* @param {object} state Shared state bus (reads state.activeAgentCount)
|
||||
* @param {object} theme Theme bus (reads theme.colors.accent)
|
||||
*/
|
||||
export function init(scene, state, theme) {
|
||||
_state = state;
|
||||
|
||||
const accentColor = theme?.colors?.accent ?? 0x4488ff;
|
||||
|
||||
const geo = new THREE.CylinderGeometry(BEAM_RADIUS, BEAM_RADIUS * 2.5, BEAM_HEIGHT, 32, 16, true);
|
||||
_beamMaterial = new THREE.MeshBasicMaterial({
|
||||
color: accentColor,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const beam = new THREE.Mesh(geo, _beamMaterial);
|
||||
beam.position.set(BEAM_X, BEAM_Y + BEAM_HEIGHT / 2, BEAM_Z);
|
||||
scene.add(beam);
|
||||
}
|
||||
|
||||
export function update(_elapsed, _delta) {
|
||||
if (!_beamMaterial) return;
|
||||
|
||||
_pulse += 0.02;
|
||||
|
||||
const agentCount = _state?.activeAgentCount ?? 0;
|
||||
const agentIntensity = agentCount === 0 ? 0.1 : Math.min(0.1 + agentCount * 0.3, 1.0);
|
||||
const pulseEffect = Math.sin(_pulse) * 0.15 * agentIntensity;
|
||||
_beamMaterial.opacity = agentIntensity * 0.6 + pulseEffect;
|
||||
}
|
||||
176
modules/effects/gravity-zones.js
Normal file
176
modules/effects/gravity-zones.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* gravity-zones.js — Rising particle gravity anomaly zones
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: state.portals (positions and online status)
|
||||
*
|
||||
* Each gravity zone is a glowing floor ring with rising particle streams.
|
||||
* Zones are initially placed at hardcoded positions, then realigned to portal
|
||||
* positions when portal data loads. Online portals have brighter/faster anomalies;
|
||||
* offline portals have dim, slow anomalies.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const ANOMALY_FLOOR = 0.2;
|
||||
const ANOMALY_CEIL = 16.0;
|
||||
|
||||
const DEFAULT_ZONES = [
|
||||
{ x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 },
|
||||
{ x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 },
|
||||
{ x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 },
|
||||
];
|
||||
|
||||
let _state = null;
|
||||
let _scene = null;
|
||||
let _portalsApplied = false;
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* zone: object,
|
||||
* ring: THREE.Mesh, ringMat: THREE.MeshBasicMaterial,
|
||||
* disc: THREE.Mesh, discMat: THREE.MeshBasicMaterial,
|
||||
* points: THREE.Points, geo: THREE.BufferGeometry,
|
||||
* driftPhases: Float32Array, velocities: Float32Array
|
||||
* }} GravityZoneObject
|
||||
*/
|
||||
|
||||
/** @type {GravityZoneObject[]} */
|
||||
const gravityZoneObjects = [];
|
||||
|
||||
function _buildZone(zone) {
|
||||
const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: zone.color, transparent: true, opacity: 0.4,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(zone.x, ANOMALY_FLOOR + 0.05, zone.z);
|
||||
_scene.add(ring);
|
||||
|
||||
const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64);
|
||||
const discMat = new THREE.MeshBasicMaterial({
|
||||
color: zone.color, transparent: true, opacity: 0.04,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
});
|
||||
const disc = new THREE.Mesh(discGeo, discMat);
|
||||
disc.rotation.x = -Math.PI / 2;
|
||||
disc.position.set(zone.x, ANOMALY_FLOOR + 0.04, zone.z);
|
||||
_scene.add(disc);
|
||||
|
||||
const count = zone.particleCount;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const driftPhases = new Float32Array(count);
|
||||
const velocities = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * zone.radius;
|
||||
positions[i * 3] = zone.x + Math.cos(angle) * r;
|
||||
positions[i * 3 + 1] = ANOMALY_FLOOR + Math.random() * (ANOMALY_CEIL - ANOMALY_FLOOR);
|
||||
positions[i * 3 + 2] = zone.z + Math.sin(angle) * r;
|
||||
driftPhases[i] = Math.random() * Math.PI * 2;
|
||||
velocities[i] = 0.03 + Math.random() * 0.04;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: zone.color, size: 0.10, sizeAttenuation: true,
|
||||
transparent: true, opacity: 0.7, depthWrite: false,
|
||||
});
|
||||
const points = new THREE.Points(geo, mat);
|
||||
_scene.add(points);
|
||||
|
||||
return { zone: { ...zone }, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} scene
|
||||
* @param {object} state Shared state bus (reads state.portals)
|
||||
* @param {object} _theme
|
||||
*/
|
||||
export function init(scene, state, _theme) {
|
||||
_scene = scene;
|
||||
_state = state;
|
||||
|
||||
for (const zone of DEFAULT_ZONES) {
|
||||
gravityZoneObjects.push(_buildZone(zone));
|
||||
}
|
||||
}
|
||||
|
||||
function _applyPortals(portals) {
|
||||
_portalsApplied = true;
|
||||
for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) {
|
||||
const portal = portals[i];
|
||||
const gz = gravityZoneObjects[i];
|
||||
const isOnline = portal.status === 'online';
|
||||
const c = new THREE.Color(portal.color);
|
||||
|
||||
gz.ring.position.set(portal.position.x, ANOMALY_FLOOR + 0.05, portal.position.z);
|
||||
gz.disc.position.set(portal.position.x, ANOMALY_FLOOR + 0.04, portal.position.z);
|
||||
gz.zone.x = portal.position.x;
|
||||
gz.zone.z = portal.position.z;
|
||||
gz.zone.color = c.getHex();
|
||||
|
||||
gz.ringMat.color.copy(c);
|
||||
gz.discMat.color.copy(c);
|
||||
gz.points.material.color.copy(c);
|
||||
|
||||
gz.ringMat.opacity = isOnline ? 0.4 : 0.08;
|
||||
gz.discMat.opacity = isOnline ? 0.04 : 0.01;
|
||||
gz.points.material.opacity = isOnline ? 0.7 : 0.15;
|
||||
|
||||
// Reposition particles around portal
|
||||
const pos = gz.geo.attributes.position.array;
|
||||
for (let j = 0; j < gz.zone.particleCount; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * gz.zone.radius;
|
||||
pos[j * 3] = gz.zone.x + Math.cos(angle) * r;
|
||||
pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
|
||||
}
|
||||
gz.geo.attributes.position.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function update(elapsed, _delta) {
|
||||
// Align to portal data once it loads
|
||||
if (!_portalsApplied) {
|
||||
const portals = _state?.portals ?? [];
|
||||
if (portals.length > 0) _applyPortals(portals);
|
||||
}
|
||||
|
||||
for (const gz of gravityZoneObjects) {
|
||||
const pos = gz.geo.attributes.position.array;
|
||||
const count = gz.zone.particleCount;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
pos[i * 3 + 1] += gz.velocities[i];
|
||||
pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003;
|
||||
pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003;
|
||||
|
||||
if (pos[i * 3 + 1] > ANOMALY_CEIL) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * gz.zone.radius;
|
||||
pos[i * 3] = gz.zone.x + Math.cos(angle) * r;
|
||||
pos[i * 3 + 1] = ANOMALY_FLOOR + Math.random() * 2.0;
|
||||
pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
|
||||
}
|
||||
}
|
||||
gz.geo.attributes.position.needsUpdate = true;
|
||||
|
||||
// Breathing glow pulse on ring/disc
|
||||
gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15;
|
||||
gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-align zones to current portal data.
|
||||
* Call after portal health check updates portal statuses.
|
||||
*/
|
||||
export function rebuildFromPortals() {
|
||||
const portals = _state?.portals ?? [];
|
||||
if (portals.length > 0) _applyPortals(portals);
|
||||
}
|
||||
196
modules/effects/lightning.js
Normal file
196
modules/effects/lightning.js
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* lightning.js — Floating crystals and lightning arcs between them
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: state.zoneIntensity (commit activity drives arc count + intensity)
|
||||
*
|
||||
* Five octahedral crystals float above the platform. Lightning arcs jump
|
||||
* between them when zone activity is high. Crystal count and colors are
|
||||
* aligned to the five agent zones.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const CRYSTAL_COUNT = 5;
|
||||
const CRYSTAL_BASE_POSITIONS = [
|
||||
new THREE.Vector3(-4.5, 3.2, -3.8),
|
||||
new THREE.Vector3( 4.8, 2.8, -4.0),
|
||||
new THREE.Vector3(-5.5, 4.0, 1.5),
|
||||
new THREE.Vector3( 5.2, 3.5, 2.0),
|
||||
new THREE.Vector3( 0.0, 5.0, -5.5),
|
||||
];
|
||||
// Zone colors: Claude, Timmy, Kimi, Perplexity, center
|
||||
const CRYSTAL_COLORS = [0xff6440, 0x40a0ff, 0x40ff8c, 0xc840ff, 0xffd700];
|
||||
|
||||
const LIGHTNING_POOL_SIZE = 6;
|
||||
const LIGHTNING_SEGMENTS = 8;
|
||||
const LIGHTNING_REFRESH_MS = 130;
|
||||
|
||||
let _state = null;
|
||||
|
||||
/** @type {THREE.Scene|null} */
|
||||
let _scene = null;
|
||||
|
||||
/** @type {Array<{mesh: THREE.Mesh, light: THREE.PointLight, basePos: THREE.Vector3, floatPhase: number, flashStartTime: number}>} */
|
||||
const crystals = [];
|
||||
|
||||
/** @type {THREE.Line[]} */
|
||||
const lightningArcs = [];
|
||||
|
||||
/** @type {Array<{active: boolean, baseOpacity: number, srcIdx: number, dstIdx: number}>} */
|
||||
const lightningArcMeta = [];
|
||||
|
||||
let _lastLightningRefreshTime = 0;
|
||||
|
||||
function _totalActivity() {
|
||||
if (!_state) return 0;
|
||||
if (typeof _state.totalActivity === 'function') return _state.totalActivity();
|
||||
const zi = _state.zoneIntensity;
|
||||
if (!zi) return 0;
|
||||
const vals = Object.values(zi);
|
||||
return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
|
||||
}
|
||||
|
||||
function _lerpColor(colorA, colorB, t) {
|
||||
const ar = (colorA >> 16) & 0xff, ag = (colorA >> 8) & 0xff, ab = colorA & 0xff;
|
||||
const br = (colorB >> 16) & 0xff, bg = (colorB >> 8) & 0xff, bb = colorB & 0xff;
|
||||
return (Math.round(ar + (br - ar) * t) << 16) |
|
||||
(Math.round(ag + (bg - ag) * t) << 8) |
|
||||
Math.round(ab + (bb - ab) * t);
|
||||
}
|
||||
|
||||
function _buildLightningPath(start, end, jagAmount) {
|
||||
const out = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
|
||||
for (let s = 0; s <= LIGHTNING_SEGMENTS; s++) {
|
||||
const t = s / LIGHTNING_SEGMENTS;
|
||||
const jag = s > 0 && s < LIGHTNING_SEGMENTS ? (Math.random() - 0.5) * jagAmount : 0;
|
||||
out[s * 3] = start.x + (end.x - start.x) * t + jag;
|
||||
out[s * 3 + 1] = start.y + (end.y - start.y) * t + jag;
|
||||
out[s * 3 + 2] = start.z + (end.z - start.z) * t + jag;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} scene
|
||||
* @param {object} state Shared state bus (reads state.zoneIntensity)
|
||||
* @param {object} _theme
|
||||
*/
|
||||
export function init(scene, state, _theme) {
|
||||
_scene = scene;
|
||||
_state = state;
|
||||
|
||||
const crystalGroup = new THREE.Group();
|
||||
scene.add(crystalGroup);
|
||||
|
||||
for (let i = 0; i < CRYSTAL_COUNT; i++) {
|
||||
const geo = new THREE.OctahedronGeometry(0.35, 0);
|
||||
const color = CRYSTAL_COLORS[i];
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: new THREE.Color(color).multiplyScalar(0.6),
|
||||
roughness: 0.05,
|
||||
metalness: 0.3,
|
||||
transparent: true,
|
||||
opacity: 0.88,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
const basePos = CRYSTAL_BASE_POSITIONS[i].clone();
|
||||
mesh.position.copy(basePos);
|
||||
mesh.userData.zoomLabel = 'Crystal';
|
||||
crystalGroup.add(mesh);
|
||||
|
||||
const light = new THREE.PointLight(color, 0.3, 6);
|
||||
light.position.copy(basePos);
|
||||
crystalGroup.add(light);
|
||||
|
||||
crystals.push({ mesh, light, basePos, floatPhase: (i / CRYSTAL_COUNT) * Math.PI * 2, flashStartTime: -999 });
|
||||
}
|
||||
|
||||
// Pre-allocate lightning arc pool
|
||||
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
||||
const positions = new Float32Array((LIGHTNING_SEGMENTS + 1) * 3);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const mat = new THREE.LineBasicMaterial({
|
||||
color: 0x88ccff,
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const arc = new THREE.Line(geo, mat);
|
||||
scene.add(arc);
|
||||
lightningArcs.push(arc);
|
||||
lightningArcMeta.push({ active: false, baseOpacity: 0, srcIdx: 0, dstIdx: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function _refreshLightningArcs(elapsed) {
|
||||
const activity = _totalActivity();
|
||||
const activeCount = Math.round(activity * LIGHTNING_POOL_SIZE);
|
||||
|
||||
for (let i = 0; i < LIGHTNING_POOL_SIZE; i++) {
|
||||
const arc = lightningArcs[i];
|
||||
const meta = lightningArcMeta[i];
|
||||
if (i >= activeCount) {
|
||||
arc.material.opacity = 0;
|
||||
meta.active = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const a = Math.floor(Math.random() * CRYSTAL_COUNT);
|
||||
let b = Math.floor(Math.random() * (CRYSTAL_COUNT - 1));
|
||||
if (b >= a) b++;
|
||||
|
||||
const jagAmount = 0.45 + activity * 0.85;
|
||||
const path = _buildLightningPath(crystals[a].mesh.position, crystals[b].mesh.position, jagAmount);
|
||||
const attr = arc.geometry.attributes.position;
|
||||
attr.array.set(path);
|
||||
attr.needsUpdate = true;
|
||||
|
||||
arc.material.color.setHex(_lerpColor(CRYSTAL_COLORS[a], CRYSTAL_COLORS[b], 0.5));
|
||||
const base = (0.35 + Math.random() * 0.55) * Math.min(activity * 1.5, 1.0);
|
||||
arc.material.opacity = base;
|
||||
meta.active = true;
|
||||
meta.baseOpacity = base;
|
||||
meta.srcIdx = a;
|
||||
meta.dstIdx = b;
|
||||
|
||||
crystals[a].flashStartTime = elapsed;
|
||||
crystals[b].flashStartTime = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function update(elapsed, _delta) {
|
||||
const activity = _totalActivity();
|
||||
|
||||
// Float crystals
|
||||
for (let i = 0; i < crystals.length; i++) {
|
||||
const c = crystals[i];
|
||||
c.mesh.position.y = c.basePos.y + Math.sin(elapsed * 0.7 + c.floatPhase) * 0.3;
|
||||
c.light.position.y = c.mesh.position.y;
|
||||
|
||||
// Brief emissive flash on lightning strike
|
||||
const flashAge = elapsed - c.flashStartTime;
|
||||
const flashIntensity = flashAge < 0.15 ? (1.0 - flashAge / 0.15) : 0;
|
||||
c.mesh.material.emissiveIntensity = 0.6 + flashIntensity * 1.2;
|
||||
c.light.intensity = 0.3 + flashIntensity * 1.5;
|
||||
|
||||
// Color intensity tethered to total activity
|
||||
c.mesh.material.opacity = 0.7 + activity * 0.18;
|
||||
}
|
||||
|
||||
// Flicker active arcs
|
||||
for (let i = 0; i < lightningArcMeta.length; i++) {
|
||||
const meta = lightningArcMeta[i];
|
||||
if (!meta.active) continue;
|
||||
lightningArcs[i].material.opacity = meta.baseOpacity * (0.7 + Math.random() * 0.3);
|
||||
}
|
||||
|
||||
// Periodically rebuild arcs
|
||||
if (elapsed * 1000 - _lastLightningRefreshTime > LIGHTNING_REFRESH_MS) {
|
||||
_lastLightningRefreshTime = elapsed * 1000;
|
||||
_refreshLightningArcs(elapsed);
|
||||
}
|
||||
}
|
||||
106
modules/effects/matrix-rain.js
Normal file
106
modules/effects/matrix-rain.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* matrix-rain.js — Commit-density-driven 2D canvas matrix rain
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: state.zoneIntensity (commit activity) + state.commitHashes
|
||||
*
|
||||
* Renders a Katakana/hex character rain behind the Three.js canvas.
|
||||
* Density and speed are tethered to commit zone activity.
|
||||
* Real commit hashes are occasionally injected as characters.
|
||||
*/
|
||||
|
||||
const MATRIX_CHARS = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン0123456789ABCDEF';
|
||||
const MATRIX_FONT_SIZE = 14;
|
||||
|
||||
let _state = null;
|
||||
let _canvas = null;
|
||||
let _ctx = null;
|
||||
let _drops = [];
|
||||
|
||||
/**
|
||||
* Computes mean activity [0..1] across all agent zones via state.
|
||||
* @returns {number}
|
||||
*/
|
||||
function _totalActivity() {
|
||||
if (!_state) return 0;
|
||||
if (typeof _state.totalActivity === 'function') return _state.totalActivity();
|
||||
const zi = _state.zoneIntensity;
|
||||
if (!zi) return 0;
|
||||
const vals = Object.values(zi);
|
||||
return vals.reduce((s, v) => s + v, 0) / Math.max(vals.length, 1);
|
||||
}
|
||||
|
||||
function _draw() {
|
||||
if (!_canvas || !_ctx) return;
|
||||
const activity = _totalActivity();
|
||||
const commitHashes = _state?.commitHashes ?? [];
|
||||
|
||||
// Fade previous frame — creates the trailing glow
|
||||
_ctx.fillStyle = 'rgba(0, 0, 8, 0.05)';
|
||||
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
|
||||
|
||||
_ctx.font = `${MATRIX_FONT_SIZE}px monospace`;
|
||||
|
||||
const density = 0.1 + activity * 0.9;
|
||||
const activeColCount = Math.max(1, Math.floor(_drops.length * density));
|
||||
|
||||
for (let i = 0; i < _drops.length; i++) {
|
||||
if (i >= activeColCount) {
|
||||
if (_drops[i] * MATRIX_FONT_SIZE > _canvas.height) continue;
|
||||
}
|
||||
|
||||
let char;
|
||||
if (commitHashes.length > 0 && Math.random() < 0.02) {
|
||||
const hash = commitHashes[Math.floor(Math.random() * commitHashes.length)];
|
||||
char = hash[Math.floor(Math.random() * hash.length)];
|
||||
} else {
|
||||
char = MATRIX_CHARS[Math.floor(Math.random() * MATRIX_CHARS.length)];
|
||||
}
|
||||
|
||||
_ctx.fillStyle = '#aaffaa';
|
||||
_ctx.fillText(char, i * MATRIX_FONT_SIZE, _drops[i] * MATRIX_FONT_SIZE);
|
||||
|
||||
const resetThreshold = 0.975 - activity * 0.015;
|
||||
if (_drops[i] * MATRIX_FONT_SIZE > _canvas.height && Math.random() > resetThreshold) {
|
||||
_drops[i] = 0;
|
||||
}
|
||||
_drops[i]++;
|
||||
}
|
||||
}
|
||||
|
||||
function _resetDrops() {
|
||||
const colCount = Math.floor(window.innerWidth / MATRIX_FONT_SIZE);
|
||||
_drops = new Array(colCount).fill(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} _scene (unused — 2D canvas effect)
|
||||
* @param {object} state Shared state bus
|
||||
* @param {object} _theme (unused — color is hardcoded green for matrix aesthetic)
|
||||
*/
|
||||
export function init(_scene, state, _theme) {
|
||||
_state = state;
|
||||
|
||||
_canvas = document.createElement('canvas');
|
||||
_canvas.id = 'matrix-rain';
|
||||
_canvas.width = window.innerWidth;
|
||||
_canvas.height = window.innerHeight;
|
||||
document.body.appendChild(_canvas);
|
||||
|
||||
_ctx = _canvas.getContext('2d');
|
||||
_resetDrops();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
_canvas.width = window.innerWidth;
|
||||
_canvas.height = window.innerHeight;
|
||||
_resetDrops();
|
||||
});
|
||||
|
||||
// Run at ~20 fps independent of the Three.js RAF loop
|
||||
setInterval(_draw, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* update() is a no-op — rain runs on its own setInterval.
|
||||
*/
|
||||
export function update(_elapsed, _delta) {}
|
||||
138
modules/effects/rune-ring.js
Normal file
138
modules/effects/rune-ring.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* rune-ring.js — Orbiting Elder Futhark rune sprites
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: state.portals (count, colors, and online status from portals.json)
|
||||
*
|
||||
* Rune sprites orbit the scene in a ring. Count matches the portal count,
|
||||
* colors come from portal colors, and brightness reflects portal online status.
|
||||
* A faint torus marks the orbit track.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const RUNE_RING_RADIUS = 7.0;
|
||||
const RUNE_RING_Y = 1.5;
|
||||
const RUNE_ORBIT_SPEED = 0.08; // radians per second
|
||||
const DEFAULT_RUNE_COUNT = 12;
|
||||
|
||||
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','ᚲ','ᚷ','ᚹ','ᚺ','ᚾ','ᛁ','ᛃ'];
|
||||
const FALLBACK_COLORS = ['#00ffcc', '#ff44ff'];
|
||||
|
||||
let _scene = null;
|
||||
let _state = null;
|
||||
|
||||
/** @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} */
|
||||
const runeSprites = [];
|
||||
|
||||
let _orbitRingMesh = null;
|
||||
let _builtForPortalCount = -1;
|
||||
|
||||
function _createRuneTexture(glyph, color) {
|
||||
const W = 128, H = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 28;
|
||||
ctx.font = 'bold 78px serif';
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(glyph, W / 2, H / 2);
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function _clearSprites() {
|
||||
for (const rune of runeSprites) {
|
||||
_scene.remove(rune.sprite);
|
||||
if (rune.sprite.material.map) rune.sprite.material.map.dispose();
|
||||
rune.sprite.material.dispose();
|
||||
}
|
||||
runeSprites.length = 0;
|
||||
}
|
||||
|
||||
function _build(portals) {
|
||||
_clearSprites();
|
||||
|
||||
const count = portals ? portals.length : DEFAULT_RUNE_COUNT;
|
||||
_builtForPortalCount = count;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
|
||||
const color = portals ? portals[i].color : FALLBACK_COLORS[i % FALLBACK_COLORS.length];
|
||||
const isOnline = portals ? portals[i].status === 'online' : true;
|
||||
const texture = _createRuneTexture(glyph, color);
|
||||
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: isOnline ? 1.0 : 0.15,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
sprite.scale.set(1.3, 1.3, 1);
|
||||
|
||||
const baseAngle = (i / count) * Math.PI * 2;
|
||||
sprite.position.set(
|
||||
Math.cos(baseAngle) * RUNE_RING_RADIUS,
|
||||
RUNE_RING_Y,
|
||||
Math.sin(baseAngle) * RUNE_RING_RADIUS
|
||||
);
|
||||
_scene.add(sprite);
|
||||
runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} scene
|
||||
* @param {object} state Shared state bus (reads state.portals)
|
||||
* @param {object} _theme
|
||||
*/
|
||||
export function init(scene, state, _theme) {
|
||||
_scene = scene;
|
||||
_state = state;
|
||||
|
||||
// Faint orbit track torus
|
||||
const ringGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({ color: 0x224466, transparent: true, opacity: 0.22 });
|
||||
_orbitRingMesh = new THREE.Mesh(ringGeo, ringMat);
|
||||
_orbitRingMesh.rotation.x = Math.PI / 2;
|
||||
_orbitRingMesh.position.y = RUNE_RING_Y;
|
||||
scene.add(_orbitRingMesh);
|
||||
|
||||
// Initial build with defaults — will be rebuilt when portals load
|
||||
_build(null);
|
||||
}
|
||||
|
||||
export function update(elapsed, _delta) {
|
||||
// Rebuild rune sprites when portal data changes
|
||||
const portals = _state?.portals ?? [];
|
||||
if (portals.length > 0 && portals.length !== _builtForPortalCount) {
|
||||
_build(portals);
|
||||
}
|
||||
|
||||
// Orbit and float
|
||||
for (const rune of runeSprites) {
|
||||
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
|
||||
rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
|
||||
rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
|
||||
rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4;
|
||||
|
||||
const baseOpacity = rune.portalOnline ? 0.85 : 0.12;
|
||||
const pulseRange = rune.portalOnline ? 0.15 : 0.03;
|
||||
rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a rebuild from current portal data.
|
||||
* Called externally after portal health checks update statuses.
|
||||
*/
|
||||
export function rebuild() {
|
||||
const portals = _state?.portals ?? [];
|
||||
_build(portals.length > 0 ? portals : null);
|
||||
}
|
||||
183
modules/effects/shockwave.js
Normal file
183
modules/effects/shockwave.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* shockwave.js — Shockwave ripple, fireworks, and merge flash
|
||||
*
|
||||
* Category: DATA-TETHERED AESTHETIC
|
||||
* Data source: PR merge events (WebSocket/event dispatch)
|
||||
*
|
||||
* Triggered externally on merge events:
|
||||
* - triggerShockwave() — expanding concentric ring waves from scene centre
|
||||
* - triggerFireworks() — multi-burst particle fireworks above the platform
|
||||
* - triggerMergeFlash() — both of the above + star/constellation color flash
|
||||
*
|
||||
* The merge flash accepts optional callbacks so terrain/stars.js can own
|
||||
* its own state while shockwave.js coordinates the event.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const SHOCKWAVE_RING_COUNT = 3;
|
||||
const SHOCKWAVE_MAX_RADIUS = 14;
|
||||
const SHOCKWAVE_DURATION = 2.5; // seconds
|
||||
|
||||
const FIREWORK_COLORS = [0xff4466, 0xffaa00, 0x00ffaa, 0x4488ff, 0xff44ff, 0xffff44, 0x00ffff];
|
||||
const FIREWORK_BURST_PARTICLES = 80;
|
||||
const FIREWORK_BURST_DURATION = 2.2; // seconds
|
||||
const FIREWORK_GRAVITY = -5.0;
|
||||
|
||||
let _scene = null;
|
||||
let _clock = null;
|
||||
|
||||
/**
|
||||
* @typedef {{mesh: THREE.Mesh, mat: THREE.MeshBasicMaterial, startTime: number, delay: number}} ShockwaveRing
|
||||
* @typedef {{points: THREE.Points, geo: THREE.BufferGeometry, mat: THREE.PointsMaterial, origins: Float32Array, velocities: Float32Array, startTime: number}} FireworkBurst
|
||||
*/
|
||||
|
||||
/** @type {ShockwaveRing[]} */
|
||||
const shockwaveRings = [];
|
||||
|
||||
/** @type {FireworkBurst[]} */
|
||||
const fireworkBursts = [];
|
||||
|
||||
/**
|
||||
* Optional callbacks injected via init() for the merge flash star/constellation effect.
|
||||
* terrain/stars.js can register its own handler when it is initialized.
|
||||
* @type {Array<() => void>}
|
||||
*/
|
||||
const _mergeFlashCallbacks = [];
|
||||
|
||||
/**
|
||||
* @param {THREE.Scene} scene
|
||||
* @param {object} _state (unused — triggered by events, not state polling)
|
||||
* @param {object} _theme
|
||||
* @param {{ clock: THREE.Clock }} options Pass the shared clock in.
|
||||
*/
|
||||
export function init(scene, _state, _theme, options = {}) {
|
||||
_scene = scene;
|
||||
_clock = options.clock ?? new THREE.Clock();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an external callback to be called during triggerMergeFlash().
|
||||
* Use this to let other modules (stars, constellation lines) animate their own flash.
|
||||
* @param {() => void} fn
|
||||
*/
|
||||
export function onMergeFlash(fn) {
|
||||
_mergeFlashCallbacks.push(fn);
|
||||
}
|
||||
|
||||
export function triggerShockwave() {
|
||||
if (!_scene || !_clock) return;
|
||||
const now = _clock.getElapsedTime();
|
||||
for (let i = 0; i < SHOCKWAVE_RING_COUNT; i++) {
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ffff, transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide, depthWrite: false, blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const geo = new THREE.RingGeometry(0.9, 1.0, 64);
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.y = 0.02;
|
||||
_scene.add(mesh);
|
||||
shockwaveRings.push({ mesh, mat, startTime: now, delay: i * 0.35 });
|
||||
}
|
||||
}
|
||||
|
||||
function _spawnFireworkBurst(origin, color) {
|
||||
if (!_scene || !_clock) return;
|
||||
const now = _clock.getElapsedTime();
|
||||
const count = FIREWORK_BURST_PARTICLES;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const origins = new Float32Array(count * 3);
|
||||
const velocities = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const speed = 2.5 + Math.random() * 3.5;
|
||||
velocities[i * 3] = Math.sin(phi) * Math.cos(theta) * speed;
|
||||
velocities[i * 3 + 1] = Math.sin(phi) * Math.sin(theta) * speed;
|
||||
velocities[i * 3 + 2] = Math.cos(phi) * speed;
|
||||
origins[i * 3] = origin.x;
|
||||
origins[i * 3 + 1] = origin.y;
|
||||
origins[i * 3 + 2] = origin.z;
|
||||
positions[i * 3] = origin.x;
|
||||
positions[i * 3 + 1] = origin.y;
|
||||
positions[i * 3 + 2] = origin.z;
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color, size: 0.35, sizeAttenuation: true,
|
||||
transparent: true, opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
const points = new THREE.Points(geo, mat);
|
||||
_scene.add(points);
|
||||
fireworkBursts.push({ points, geo, mat, origins, velocities, startTime: now });
|
||||
}
|
||||
|
||||
export function triggerFireworks() {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const delay = i * 0.35;
|
||||
setTimeout(() => {
|
||||
const x = (Math.random() - 0.5) * 12;
|
||||
const y = 8 + Math.random() * 6;
|
||||
const z = (Math.random() - 0.5) * 12;
|
||||
const color = FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
|
||||
_spawnFireworkBurst(new THREE.Vector3(x, y, z), color);
|
||||
}, delay * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerMergeFlash() {
|
||||
triggerShockwave();
|
||||
// Notify registered handlers (e.g. terrain/stars.js)
|
||||
for (const fn of _mergeFlashCallbacks) fn();
|
||||
}
|
||||
|
||||
export function update(elapsed, _delta) {
|
||||
// Animate shockwave rings
|
||||
for (let i = shockwaveRings.length - 1; i >= 0; i--) {
|
||||
const ring = shockwaveRings[i];
|
||||
const age = elapsed - ring.startTime - ring.delay;
|
||||
if (age < 0) continue;
|
||||
const t = Math.min(age / SHOCKWAVE_DURATION, 1);
|
||||
if (t >= 1) {
|
||||
_scene.remove(ring.mesh);
|
||||
ring.mesh.geometry.dispose();
|
||||
ring.mat.dispose();
|
||||
shockwaveRings.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
const eased = 1 - Math.pow(1 - t, 2);
|
||||
ring.mesh.scale.setScalar(eased * SHOCKWAVE_MAX_RADIUS + 0.1);
|
||||
ring.mat.opacity = (1 - t) * 0.9;
|
||||
}
|
||||
|
||||
// Animate firework bursts
|
||||
for (let i = fireworkBursts.length - 1; i >= 0; i--) {
|
||||
const burst = fireworkBursts[i];
|
||||
const age = elapsed - burst.startTime;
|
||||
const t = Math.min(age / FIREWORK_BURST_DURATION, 1);
|
||||
if (t >= 1) {
|
||||
_scene.remove(burst.points);
|
||||
burst.geo.dispose();
|
||||
burst.mat.dispose();
|
||||
fireworkBursts.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
burst.mat.opacity = t < 0.6 ? 1.0 : (1.0 - t) / 0.4;
|
||||
|
||||
const pos = burst.geo.attributes.position.array;
|
||||
const vel = burst.velocities;
|
||||
const org = burst.origins;
|
||||
const halfGAge2 = 0.5 * FIREWORK_GRAVITY * age * age;
|
||||
for (let j = 0; j < FIREWORK_BURST_PARTICLES; j++) {
|
||||
pos[j * 3] = org[j * 3] + vel[j * 3] * age;
|
||||
pos[j * 3 + 1] = org[j * 3 + 1] + vel[j * 3 + 1] * age + halfGAge2;
|
||||
pos[j * 3 + 2] = org[j * 3 + 2] + vel[j * 3 + 2] * age;
|
||||
}
|
||||
burst.geo.attributes.position.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
191
modules/panels/agent-board.js
Normal file
191
modules/panels/agent-board.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// modules/panels/agent-board.js — Agent status holographic board
|
||||
// Reads state.agentStatus (populated by data/gitea.js) and renders one floating
|
||||
// sprite panel per agent. Board arcs behind the platform on the negative-Z side.
|
||||
//
|
||||
// Data category: REAL
|
||||
// Data source: state.agentStatus (Gitea commits + open PRs via data/gitea.js)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../core/state.js';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
const BOARD_RADIUS = 9.5;
|
||||
const BOARD_Y = 4.2;
|
||||
const BOARD_SPREAD = Math.PI * 0.75; // 135° arc, centred on -Z
|
||||
|
||||
const STATUS_COLOR = {
|
||||
working: NEXUS.theme.agentWorking,
|
||||
idle: NEXUS.theme.agentIdle,
|
||||
dormant: NEXUS.theme.agentDormant,
|
||||
dead: NEXUS.theme.agentDead,
|
||||
unreachable: NEXUS.theme.agentDead,
|
||||
};
|
||||
|
||||
let _group, _scene;
|
||||
let _lastAgentStatus = null;
|
||||
let _sprites = [];
|
||||
|
||||
/**
|
||||
* Builds a canvas texture for a single agent holo-panel.
|
||||
* @param {{ name: string, status: string, issue: string|null, prs_today: number, local: boolean }} agent
|
||||
* @returns {THREE.CanvasTexture}
|
||||
*/
|
||||
function _makeTexture(agent) {
|
||||
const W = 400, H = 200;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const sc = STATUS_COLOR[agent.status] || NEXUS.theme.accentStr;
|
||||
const font = NEXUS.theme.fontMono;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.strokeStyle = sc;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
// Agent name
|
||||
ctx.font = `bold 28px ${font}`;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(agent.name.toUpperCase(), 16, 44);
|
||||
|
||||
// Status dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
|
||||
ctx.fillStyle = sc;
|
||||
ctx.fill();
|
||||
|
||||
// Status label
|
||||
ctx.font = `13px ${font}`;
|
||||
ctx.fillStyle = sc;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
|
||||
|
||||
// Separator
|
||||
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke();
|
||||
|
||||
// Current issue
|
||||
ctx.font = `10px ${font}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelDim;
|
||||
ctx.fillText('CURRENT ISSUE', 16, 90);
|
||||
|
||||
ctx.font = `13px ${font}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelText;
|
||||
const raw = agent.issue || '\u2014 none \u2014';
|
||||
ctx.fillText(raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw, 16, 110);
|
||||
|
||||
// Separator
|
||||
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
|
||||
ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke();
|
||||
|
||||
// PRs label + count
|
||||
ctx.font = `10px ${font}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelDim;
|
||||
ctx.fillText('PRs MERGED TODAY', 16, 148);
|
||||
|
||||
ctx.font = `bold 28px ${font}`;
|
||||
ctx.fillStyle = NEXUS.theme.accentStr;
|
||||
ctx.fillText(String(agent.prs_today), 16, 182);
|
||||
|
||||
// Runtime indicator
|
||||
const isLocal = agent.local === true;
|
||||
const rtColor = isLocal ? NEXUS.theme.agentWorking : NEXUS.theme.agentDead;
|
||||
const rtLabel = isLocal ? 'LOCAL' : 'CLOUD';
|
||||
|
||||
ctx.font = `10px ${font}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelDim;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('RUNTIME', W - 16, 148);
|
||||
|
||||
ctx.font = `bold 13px ${font}`;
|
||||
ctx.fillStyle = rtColor;
|
||||
ctx.fillText(rtLabel, W - 28, 172);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(W - 16, 167, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = rtColor;
|
||||
ctx.fill();
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function _rebuild(statusData) {
|
||||
// Remove old sprites
|
||||
while (_group.children.length) _group.remove(_group.children[0]);
|
||||
for (const s of _sprites) {
|
||||
if (s.material.map) s.material.map.dispose();
|
||||
s.material.dispose();
|
||||
}
|
||||
_sprites = [];
|
||||
|
||||
const agents = statusData.agents;
|
||||
const n = agents.length;
|
||||
agents.forEach((agent, i) => {
|
||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
|
||||
const x = Math.cos(angle) * BOARD_RADIUS;
|
||||
const z = Math.sin(angle) * BOARD_RADIUS;
|
||||
|
||||
const texture = _makeTexture(agent);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.set(6.4, 3.2, 1);
|
||||
sprite.position.set(x, BOARD_Y, z);
|
||||
sprite.userData = {
|
||||
baseY: BOARD_Y,
|
||||
floatPhase: (i / n) * Math.PI * 2,
|
||||
floatSpeed: 0.18 + i * 0.04,
|
||||
zoomLabel: `Agent: ${agent.name}`,
|
||||
};
|
||||
_group.add(sprite);
|
||||
_sprites.push(sprite);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
_group = new THREE.Group();
|
||||
scene.add(_group);
|
||||
|
||||
// If state already has agent data (unlikely on first load, but handle it)
|
||||
if (state.agentStatus) {
|
||||
_rebuild(state.agentStatus);
|
||||
_lastAgentStatus = state.agentStatus;
|
||||
}
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} elapsed
|
||||
* @param {number} delta
|
||||
*/
|
||||
export function update(elapsed, delta) {
|
||||
// Rebuild board when state.agentStatus changes
|
||||
if (state.agentStatus && state.agentStatus !== _lastAgentStatus) {
|
||||
_rebuild(state.agentStatus);
|
||||
_lastAgentStatus = state.agentStatus;
|
||||
}
|
||||
|
||||
// Animate gentle float
|
||||
for (const sprite of _sprites) {
|
||||
const ud = sprite.userData;
|
||||
sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_group) _scene.remove(_group);
|
||||
}
|
||||
200
modules/panels/dual-brain.js
Normal file
200
modules/panels/dual-brain.js
Normal file
@@ -0,0 +1,200 @@
|
||||
// modules/panels/dual-brain.js — Dual-Brain Status holographic panel
|
||||
// Shows the Brain Gap Scorecard with two glowing brain orbs.
|
||||
// Displayed as HONEST-OFFLINE: the dual-brain system is not yet deployed.
|
||||
// Brain pulse particles are set to ZERO — will flow when system comes online.
|
||||
//
|
||||
// Data category: HONEST-OFFLINE
|
||||
// Data source: — (dual-brain system not deployed; shows "AWAITING DEPLOYMENT")
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
const ORIGIN = new THREE.Vector3(10, 3, -8);
|
||||
const OFFLINE_COLOR = NEXUS.theme.agentDormantHex; // dim blue — system offline
|
||||
const ACCENT = NEXUS.theme.accentStr;
|
||||
const FONT = NEXUS.theme.fontMono;
|
||||
|
||||
let _group, _sprite, _scanSprite, _scanCanvas, _scanCtx, _scanTexture;
|
||||
let _cloudOrb, _localOrb;
|
||||
let _scene;
|
||||
|
||||
function _buildPanelTexture() {
|
||||
const W = 512, H = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = NEXUS.theme.panelBg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.strokeStyle = ACCENT;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||||
ctx.strokeStyle = '#223366';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(5, 5, W - 10, H - 10);
|
||||
|
||||
// Title
|
||||
ctx.font = `bold 22px ${FONT}`;
|
||||
ctx.fillStyle = '#88ccff';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40);
|
||||
|
||||
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
|
||||
ctx.beginPath(); ctx.moveTo(20, 52); ctx.lineTo(W - 20, 52); ctx.stroke();
|
||||
|
||||
// Section header
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelDim;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('BRAIN GAP SCORECARD', 20, 74);
|
||||
|
||||
const categories = ['Triage', 'Tool Use', 'Code Gen', 'Planning', 'Communication', 'Reasoning'];
|
||||
const barX = 20, barW = W - 130, barH = 20;
|
||||
let y = 90;
|
||||
|
||||
for (const cat of categories) {
|
||||
ctx.font = `13px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.agentDormant;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(cat, barX, y + 14);
|
||||
|
||||
ctx.font = `bold 13px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelVeryDim;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('\u2014', W - 20, y + 14); // em dash — no data
|
||||
y += 22;
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
|
||||
ctx.fillRect(barX, y, barW, barH); // empty bar background only
|
||||
y += barH + 12;
|
||||
}
|
||||
|
||||
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
|
||||
ctx.beginPath(); ctx.moveTo(20, y + 4); ctx.lineTo(W - 20, y + 4); ctx.stroke();
|
||||
y += 22;
|
||||
|
||||
// Honest offline status
|
||||
ctx.font = `bold 18px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelVeryDim;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10);
|
||||
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.fillStyle = '#223344';
|
||||
ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32);
|
||||
|
||||
// Brain indicators — offline dim
|
||||
y += 52;
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = NEXUS.theme.panelVeryDim;
|
||||
ctx.fill();
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelVeryDim;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('CLOUD', W / 2 - 48, y + 12);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = NEXUS.theme.panelVeryDim;
|
||||
ctx.fill();
|
||||
ctx.fillText('LOCAL', W / 2 + 42, y + 12);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
|
||||
_group = new THREE.Group();
|
||||
_group.position.copy(ORIGIN);
|
||||
_group.lookAt(0, 3, 0);
|
||||
scene.add(_group);
|
||||
|
||||
// Static panel sprite
|
||||
const texture = _buildPanelTexture();
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.92, depthWrite: false });
|
||||
_sprite = new THREE.Sprite(material);
|
||||
_sprite.scale.set(5.0, 5.0, 1);
|
||||
_sprite.userData = { baseY: 0, floatPhase: 0, floatSpeed: 0.22, zoomLabel: 'Dual-Brain Status' };
|
||||
_group.add(_sprite);
|
||||
|
||||
// Accent light
|
||||
const light = new THREE.PointLight(NEXUS.theme.accent, 0.6, 10);
|
||||
light.position.set(0, 0.5, 1);
|
||||
_group.add(light);
|
||||
|
||||
// Offline brain orbs — dim
|
||||
const orbGeo = new THREE.SphereGeometry(0.35, 32, 32);
|
||||
const orbMat = (color) => new THREE.MeshStandardMaterial({
|
||||
color, emissive: new THREE.Color(color), emissiveIntensity: 0.1,
|
||||
metalness: 0.3, roughness: 0.2, transparent: true, opacity: 0.85,
|
||||
});
|
||||
|
||||
_cloudOrb = new THREE.Mesh(orbGeo, orbMat(OFFLINE_COLOR));
|
||||
_cloudOrb.position.set(-2.0, 3.0, 0);
|
||||
_cloudOrb.userData.zoomLabel = 'Cloud Brain';
|
||||
_group.add(_cloudOrb);
|
||||
|
||||
_localOrb = new THREE.Mesh(orbGeo.clone(), orbMat(OFFLINE_COLOR));
|
||||
_localOrb.position.set(2.0, 3.0, 0);
|
||||
_localOrb.userData.zoomLabel = 'Local Brain';
|
||||
_group.add(_localOrb);
|
||||
|
||||
// Brain pulse particles — ZERO count (system offline)
|
||||
const particleGeo = new THREE.BufferGeometry();
|
||||
particleGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(0), 3));
|
||||
const particleMat = new THREE.PointsMaterial({
|
||||
color: 0x44ddff, size: 0.08, sizeAttenuation: true,
|
||||
transparent: true, opacity: 0.8, depthWrite: false,
|
||||
});
|
||||
_group.add(new THREE.Points(particleGeo, particleMat));
|
||||
|
||||
// Scan line overlay
|
||||
_scanCanvas = document.createElement('canvas');
|
||||
_scanCanvas.width = 512;
|
||||
_scanCanvas.height = 512;
|
||||
_scanCtx = _scanCanvas.getContext('2d');
|
||||
_scanTexture = new THREE.CanvasTexture(_scanCanvas);
|
||||
|
||||
const scanMat = new THREE.SpriteMaterial({
|
||||
map: _scanTexture, transparent: true, opacity: 0.18, depthWrite: false,
|
||||
});
|
||||
_scanSprite = new THREE.Sprite(scanMat);
|
||||
_scanSprite.scale.set(5.0, 5.0, 1);
|
||||
_scanSprite.position.set(0, 0, 0.01);
|
||||
_group.add(_scanSprite);
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} elapsed
|
||||
* @param {number} _delta
|
||||
*/
|
||||
export function update(elapsed, _delta) {
|
||||
// Gentle float animation
|
||||
const ud = _sprite.userData;
|
||||
_sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.08;
|
||||
|
||||
// Scan line — horizontal sweep
|
||||
const W = 512, H = 512;
|
||||
_scanCtx.clearRect(0, 0, W, H);
|
||||
const scanY = ((elapsed * 60) % H);
|
||||
const grad = _scanCtx.createLinearGradient(0, scanY - 20, 0, scanY + 20);
|
||||
grad.addColorStop(0, 'rgba(68, 136, 255, 0)');
|
||||
grad.addColorStop(0.5, 'rgba(68, 136, 255, 0.4)');
|
||||
grad.addColorStop(1, 'rgba(68, 136, 255, 0)');
|
||||
_scanCtx.fillStyle = grad;
|
||||
_scanCtx.fillRect(0, scanY - 20, W, 40);
|
||||
_scanTexture.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_group) _scene.remove(_group);
|
||||
if (_scanTexture) _scanTexture.dispose();
|
||||
}
|
||||
212
modules/panels/earth.js
Normal file
212
modules/panels/earth.js
Normal file
@@ -0,0 +1,212 @@
|
||||
// modules/panels/earth.js — Holographic Earth floating above the Nexus
|
||||
// A procedural planet Earth with continent noise, scan lines, and fresnel rim glow.
|
||||
// Rotation speed is tethered to state.totalActivity() — more commits = faster spin.
|
||||
// Lat/lon grid, atmosphere shell, and a tether beam to the platform center.
|
||||
//
|
||||
// Data category: DATA-TETHERED AESTHETIC
|
||||
// Data source: state.totalActivity() (computed from state.zoneIntensity)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../core/state.js';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
const EARTH_RADIUS = 2.8;
|
||||
const EARTH_Y = 20.0;
|
||||
const EARTH_AXIAL_TILT = 23.4 * (Math.PI / 180);
|
||||
const ROTATION_SPEED_BASE = 0.02; // rad/s minimum
|
||||
const ROTATION_SPEED_MAX = 0.08; // rad/s at full activity
|
||||
|
||||
let _group, _surfaceMat, _scene;
|
||||
|
||||
const _vertexShader = `
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorldPos;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const _fragmentShader = `
|
||||
uniform float uTime;
|
||||
uniform vec3 uOceanColor;
|
||||
uniform vec3 uLandColor;
|
||||
uniform vec3 uGlowColor;
|
||||
varying vec3 vNormal;
|
||||
varying vec3 vWorldPos;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 _m3(vec3 x){ return x - floor(x*(1./289.))*289.; }
|
||||
vec4 _m4(vec4 x){ return x - floor(x*(1./289.))*289.; }
|
||||
vec4 _p4(vec4 x){ return _m4((x*34.+1.)*x); }
|
||||
float snoise(vec3 v){
|
||||
const vec2 C = vec2(1./6., 1./3.);
|
||||
vec3 i = floor(v + dot(v, C.yyy));
|
||||
vec3 x0 = v - i + dot(i, C.xxx);
|
||||
vec3 g = step(x0.yzx, x0.xyz);
|
||||
vec3 l = 1.0 - g;
|
||||
vec3 i1 = min(g.xyz, l.zxy);
|
||||
vec3 i2 = max(g.xyz, l.zxy);
|
||||
vec3 x1 = x0 - i1 + C.xxx;
|
||||
vec3 x2 = x0 - i2 + C.yyy;
|
||||
vec3 x3 = x0 - 0.5;
|
||||
i = _m3(i);
|
||||
vec4 p = _p4(_p4(_p4(
|
||||
i.z+vec4(0.,i1.z,i2.z,1.))+
|
||||
i.y+vec4(0.,i1.y,i2.y,1.))+
|
||||
i.x+vec4(0.,i1.x,i2.x,1.)));
|
||||
float n_ = .142857142857;
|
||||
vec3 ns = n_*vec3(2.,0.,-1.)+vec3(0.,-.5,1.);
|
||||
vec4 j = p - 49.*floor(p*ns.z*ns.z);
|
||||
vec4 x_ = floor(j*ns.z);
|
||||
vec4 y_ = floor(j - 7.*x_);
|
||||
vec4 h = 1. - abs(x_*(2./7.)) - abs(y_*(2./7.));
|
||||
vec4 b0 = vec4(x_.xy,y_.xy)*(2./7.);
|
||||
vec4 b1 = vec4(x_.zw,y_.zw)*(2./7.);
|
||||
vec4 s0 = floor(b0)*2.+1.; vec4 s1 = floor(b1)*2.+1.;
|
||||
vec4 sh = -step(h, vec4(0.));
|
||||
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
|
||||
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
|
||||
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y);
|
||||
vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
|
||||
vec4 nm = max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
|
||||
vec4 nr = 1.79284291400159-0.85373472095314*nm;
|
||||
p0*=nr.x; p1*=nr.y; p2*=nr.z; p3*=nr.w;
|
||||
nm = nm*nm;
|
||||
return 42.*dot(nm*nm, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 n = normalize(vNormal);
|
||||
vec3 vd = normalize(cameraPosition - vWorldPos);
|
||||
|
||||
float lat = (vUv.y - 0.5) * 3.14159265;
|
||||
float lon = vUv.x * 6.28318530;
|
||||
vec3 sp = vec3(cos(lat)*cos(lon), sin(lat), cos(lat)*sin(lon));
|
||||
|
||||
float c = snoise(sp*1.8)*0.60 + snoise(sp*3.6)*0.30 + snoise(sp*7.2)*0.10;
|
||||
float land = smoothstep(0.05, 0.30, c);
|
||||
|
||||
vec3 surf = mix(uOceanColor, uLandColor, land);
|
||||
surf = mix(surf, uGlowColor * 0.45, 0.38);
|
||||
|
||||
float scan = 0.5 + 0.5*sin(vUv.y * 220.0 + uTime * 1.8);
|
||||
scan = smoothstep(0.30, 0.70, scan) * 0.14;
|
||||
|
||||
float fresnel = pow(1.0 - max(dot(n, vd), 0.0), 4.0);
|
||||
|
||||
vec3 col = surf + scan*uGlowColor*0.9 + fresnel*uGlowColor*1.5;
|
||||
float alpha = 0.48 + fresnel * 0.42;
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
|
||||
_group = new THREE.Group();
|
||||
_group.position.set(0, EARTH_Y, 0);
|
||||
_group.rotation.z = EARTH_AXIAL_TILT;
|
||||
|
||||
// Surface shader
|
||||
_surfaceMat = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTime: { value: 0.0 },
|
||||
uOceanColor: { value: new THREE.Color(NEXUS.theme.earthOcean) },
|
||||
uLandColor: { value: new THREE.Color(NEXUS.theme.earthLand) },
|
||||
uGlowColor: { value: new THREE.Color(NEXUS.theme.earthGlow) },
|
||||
},
|
||||
vertexShader: _vertexShader,
|
||||
fragmentShader: _fragmentShader,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: THREE.FrontSide,
|
||||
});
|
||||
|
||||
const earthMesh = new THREE.Mesh(new THREE.SphereGeometry(EARTH_RADIUS, 64, 32), _surfaceMat);
|
||||
earthMesh.userData.zoomLabel = 'Planet Earth';
|
||||
_group.add(earthMesh);
|
||||
|
||||
// Lat/lon grid
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0x2266bb, transparent: true, opacity: 0.30 });
|
||||
const r = EARTH_RADIUS + 0.015;
|
||||
const SEG = 64;
|
||||
|
||||
for (let lat = -60; lat <= 60; lat += 30) {
|
||||
const phi = lat * (Math.PI / 180);
|
||||
const pts = [];
|
||||
for (let i = 0; i <= SEG; i++) {
|
||||
const th = (i / SEG) * Math.PI * 2;
|
||||
pts.push(new THREE.Vector3(Math.cos(phi)*Math.cos(th)*r, Math.sin(phi)*r, Math.cos(phi)*Math.sin(th)*r));
|
||||
}
|
||||
_group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat));
|
||||
}
|
||||
for (let lon = 0; lon < 360; lon += 30) {
|
||||
const th = lon * (Math.PI / 180);
|
||||
const pts = [];
|
||||
for (let i = 0; i <= SEG; i++) {
|
||||
const phi = (i / SEG) * Math.PI - Math.PI / 2;
|
||||
pts.push(new THREE.Vector3(Math.cos(phi)*Math.cos(th)*r, Math.sin(phi)*r, Math.cos(phi)*Math.sin(th)*r));
|
||||
}
|
||||
_group.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), lineMat));
|
||||
}
|
||||
|
||||
// Atmosphere shell
|
||||
_group.add(new THREE.Mesh(
|
||||
new THREE.SphereGeometry(EARTH_RADIUS * 1.14, 32, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: NEXUS.theme.earthAtm, transparent: true, opacity: 0.07,
|
||||
side: THREE.BackSide, depthWrite: false, blending: THREE.AdditiveBlending,
|
||||
})
|
||||
));
|
||||
|
||||
// Glow light
|
||||
_group.add(new THREE.PointLight(NEXUS.theme.earthGlow, 0.4, 25));
|
||||
|
||||
_group.traverse(obj => {
|
||||
if (obj.isMesh || obj.isLine) obj.userData.zoomLabel = 'Planet Earth';
|
||||
});
|
||||
|
||||
// Tether beam to platform
|
||||
const beamPts = [
|
||||
new THREE.Vector3(0, EARTH_Y - EARTH_RADIUS * 1.15, 0),
|
||||
new THREE.Vector3(0, 0.5, 0),
|
||||
];
|
||||
scene.add(new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints(beamPts),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: NEXUS.theme.earthGlow, transparent: true, opacity: 0.08,
|
||||
depthWrite: false, blending: THREE.AdditiveBlending,
|
||||
})
|
||||
));
|
||||
|
||||
scene.add(_group);
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} elapsed
|
||||
* @param {number} delta
|
||||
*/
|
||||
export function update(elapsed, delta) {
|
||||
if (!_group) return;
|
||||
|
||||
// Tether rotation speed to commit activity
|
||||
const activity = state.totalActivity();
|
||||
const speed = ROTATION_SPEED_BASE + activity * (ROTATION_SPEED_MAX - ROTATION_SPEED_BASE);
|
||||
_group.rotation.y += speed * delta;
|
||||
|
||||
// Update shader time uniform for scan line animation
|
||||
_surfaceMat.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_group) _scene.remove(_group);
|
||||
if (_surfaceMat) _surfaceMat.dispose();
|
||||
}
|
||||
125
modules/panels/heatmap.js
Normal file
125
modules/panels/heatmap.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// modules/panels/heatmap.js — Commit heatmap floor overlay
|
||||
// Canvas-texture circle on the glass platform floor.
|
||||
// Each agent occupies a polar sector; recent commits make that sector glow brighter.
|
||||
// Activity decays over 24 h (driven by state.zoneIntensity, written by data/gitea.js).
|
||||
//
|
||||
// Data category: DATA-TETHERED AESTHETIC
|
||||
// Data source: state.zoneIntensity (populated from Gitea commits API by data/gitea.js)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../core/state.js';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
export const HEATMAP_ZONES = [
|
||||
{ name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 },
|
||||
{ name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 },
|
||||
{ name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 },
|
||||
{ name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 },
|
||||
];
|
||||
|
||||
const HEATMAP_SIZE = 512;
|
||||
const HEATMAP_ZONE_SPAN_RAD = Math.PI / 2; // 90° per zone
|
||||
const GLASS_RADIUS = 4.55; // matches terrain/island.js platform radius
|
||||
|
||||
let _canvas, _ctx, _texture, _mesh;
|
||||
let _scene;
|
||||
|
||||
function _draw() {
|
||||
const cx = HEATMAP_SIZE / 2;
|
||||
const cy = HEATMAP_SIZE / 2;
|
||||
const r = cx * 0.96;
|
||||
|
||||
_ctx.clearRect(0, 0, HEATMAP_SIZE, HEATMAP_SIZE);
|
||||
_ctx.save();
|
||||
_ctx.beginPath();
|
||||
_ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
_ctx.clip();
|
||||
|
||||
for (const zone of HEATMAP_ZONES) {
|
||||
const intensity = state.zoneIntensity[zone.name] || 0;
|
||||
if (intensity < 0.01) continue;
|
||||
|
||||
const [rr, gg, bb] = zone.color;
|
||||
const baseRad = zone.angleDeg * (Math.PI / 180);
|
||||
const startRad = baseRad - HEATMAP_ZONE_SPAN_RAD / 2;
|
||||
const endRad = baseRad + HEATMAP_ZONE_SPAN_RAD / 2;
|
||||
const gx = cx + Math.cos(baseRad) * r * 0.55;
|
||||
const gy = cy + Math.sin(baseRad) * r * 0.55;
|
||||
|
||||
const grad = _ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75);
|
||||
grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`);
|
||||
grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`);
|
||||
grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`);
|
||||
|
||||
_ctx.beginPath();
|
||||
_ctx.moveTo(cx, cy);
|
||||
_ctx.arc(cx, cy, r, startRad, endRad);
|
||||
_ctx.closePath();
|
||||
_ctx.fillStyle = grad;
|
||||
_ctx.fill();
|
||||
|
||||
if (intensity > 0.05) {
|
||||
const lx = cx + Math.cos(baseRad) * r * 0.62;
|
||||
const ly = cy + Math.sin(baseRad) * r * 0.62;
|
||||
_ctx.font = `bold ${Math.round(13 * intensity + 7)}px ${NEXUS.theme.fontMono}`;
|
||||
_ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`;
|
||||
_ctx.textAlign = 'center';
|
||||
_ctx.textBaseline = 'middle';
|
||||
_ctx.fillText(zone.name, lx, ly);
|
||||
}
|
||||
}
|
||||
|
||||
_ctx.restore();
|
||||
_texture.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
|
||||
_canvas = document.createElement('canvas');
|
||||
_canvas.width = HEATMAP_SIZE;
|
||||
_canvas.height = HEATMAP_SIZE;
|
||||
_ctx = _canvas.getContext('2d');
|
||||
|
||||
_texture = new THREE.CanvasTexture(_canvas);
|
||||
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: _texture,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
_mesh = new THREE.Mesh(new THREE.CircleGeometry(GLASS_RADIUS, 64), mat);
|
||||
_mesh.rotation.x = -Math.PI / 2;
|
||||
_mesh.position.y = 0.005;
|
||||
_mesh.userData.zoomLabel = 'Activity Heatmap';
|
||||
scene.add(_mesh);
|
||||
|
||||
// Draw initial empty state
|
||||
_draw();
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
let _lastDrawElapsed = 0;
|
||||
const REDRAW_INTERVAL = 0.5; // redraw at most every 500 ms (data changes slowly)
|
||||
|
||||
/**
|
||||
* @param {number} elapsed
|
||||
* @param {number} _delta
|
||||
*/
|
||||
export function update(elapsed, _delta) {
|
||||
if (elapsed - _lastDrawElapsed < REDRAW_INTERVAL) return;
|
||||
_lastDrawElapsed = elapsed;
|
||||
_draw();
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_mesh) { _scene.remove(_mesh); _mesh.geometry.dispose(); _mesh.material.dispose(); }
|
||||
if (_texture) _texture.dispose();
|
||||
}
|
||||
167
modules/panels/lora-panel.js
Normal file
167
modules/panels/lora-panel.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// modules/panels/lora-panel.js — LoRA Adapter Status holographic panel
|
||||
// Shows the model training / LoRA fine-tuning adapter status.
|
||||
// Displayed as HONEST-OFFLINE: no adapters are deployed. Panel shows empty state.
|
||||
// Will render real adapters when state.loraAdapters is populated in the future.
|
||||
//
|
||||
// Data category: HONEST-OFFLINE
|
||||
// Data source: — (no LoRA adapters deployed; shows "NO ADAPTERS DEPLOYED")
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
const PANEL_POS = new THREE.Vector3(-10.5, 4.5, 2.5);
|
||||
const LORA_ACCENT = NEXUS.theme.loraAccent;
|
||||
const LORA_ACTIVE = NEXUS.theme.loraActive;
|
||||
const LORA_OFFLINE = NEXUS.theme.loraInactive;
|
||||
const FONT = NEXUS.theme.fontMono;
|
||||
|
||||
let _group, _sprite, _scene;
|
||||
|
||||
/**
|
||||
* Builds the LoRA panel canvas texture.
|
||||
* @param {{ adapters: Array }|null} data
|
||||
* @returns {THREE.CanvasTexture}
|
||||
*/
|
||||
function _makeTexture(data) {
|
||||
const W = 420, H = 260;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = NEXUS.theme.panelBg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
ctx.strokeStyle = LORA_ACCENT;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(1, 1, W - 2, H - 2);
|
||||
ctx.strokeStyle = LORA_ACCENT;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.strokeRect(4, 4, W - 8, H - 8);
|
||||
ctx.globalAlpha = 1.0;
|
||||
|
||||
ctx.font = `bold 14px ${FONT}`;
|
||||
ctx.fillStyle = LORA_ACCENT;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('MODEL TRAINING', 14, 24);
|
||||
|
||||
ctx.font = `10px ${FONT}`;
|
||||
ctx.fillStyle = '#664488';
|
||||
ctx.fillText('LoRA ADAPTERS', 14, 38);
|
||||
|
||||
ctx.strokeStyle = '#2a1a44';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(14, 46); ctx.lineTo(W - 14, 46); ctx.stroke();
|
||||
|
||||
const adapters = data && Array.isArray(data.adapters) ? data.adapters : [];
|
||||
|
||||
if (adapters.length === 0) {
|
||||
// Honest empty state
|
||||
ctx.font = `bold 18px ${FONT}`;
|
||||
ctx.fillStyle = LORA_OFFLINE;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('NO ADAPTERS DEPLOYED', W / 2, H / 2 + 10);
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.fillStyle = '#223344';
|
||||
ctx.fillText('Adapters will appear here when trained', W / 2, H / 2 + 36);
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
// Active count header
|
||||
const activeCount = adapters.filter(a => a.active).length;
|
||||
ctx.font = `bold 13px ${FONT}`;
|
||||
ctx.fillStyle = LORA_ACTIVE;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${activeCount}/${adapters.length} ACTIVE`, W - 14, 26);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
// Adapter rows
|
||||
const ROW_H = 44;
|
||||
adapters.forEach((adapter, i) => {
|
||||
const rowY = 50 + i * ROW_H;
|
||||
const col = adapter.active ? LORA_ACTIVE : LORA_OFFLINE;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(22, rowY + 12, 6, 0, Math.PI * 2);
|
||||
ctx.fillStyle = col;
|
||||
ctx.fill();
|
||||
|
||||
ctx.font = `bold 13px ${FONT}`;
|
||||
ctx.fillStyle = adapter.active ? '#ddeeff' : '#445566';
|
||||
ctx.fillText(adapter.name, 36, rowY + 16);
|
||||
|
||||
ctx.font = `10px ${FONT}`;
|
||||
ctx.fillStyle = NEXUS.theme.panelDim;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(adapter.base, W - 14, rowY + 16);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
if (adapter.active) {
|
||||
const BX = 36, BW = W - 80, BY = rowY + 22, BH = 5;
|
||||
ctx.fillStyle = '#0a1428';
|
||||
ctx.fillRect(BX, BY, BW, BH);
|
||||
ctx.fillStyle = col;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.fillRect(BX, BY, BW * (adapter.strength || 0), BH);
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
if (i < adapters.length - 1) {
|
||||
ctx.strokeStyle = '#1a0a2a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(14, rowY + ROW_H - 2); ctx.lineTo(W - 14, rowY + ROW_H - 2); ctx.stroke();
|
||||
}
|
||||
});
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function _buildSprite(data) {
|
||||
if (_sprite) {
|
||||
_group.remove(_sprite);
|
||||
if (_sprite.material.map) _sprite.material.map.dispose();
|
||||
_sprite.material.dispose();
|
||||
_sprite = null;
|
||||
}
|
||||
const texture = _makeTexture(data);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false });
|
||||
_sprite = new THREE.Sprite(material);
|
||||
_sprite.scale.set(6.0, 3.6, 1);
|
||||
_sprite.position.copy(PANEL_POS);
|
||||
_sprite.userData = {
|
||||
baseY: PANEL_POS.y,
|
||||
floatPhase: 1.1,
|
||||
floatSpeed: 0.14,
|
||||
zoomLabel: 'Model Training — LoRA Adapters',
|
||||
};
|
||||
_group.add(_sprite);
|
||||
}
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
_group = new THREE.Group();
|
||||
scene.add(_group);
|
||||
|
||||
// Honest empty state on init — no adapters deployed
|
||||
_buildSprite({ adapters: [] });
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} elapsed
|
||||
* @param {number} _delta
|
||||
*/
|
||||
export function update(elapsed, _delta) {
|
||||
if (_sprite) {
|
||||
const ud = _sprite.userData;
|
||||
_sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.12;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_group) _scene.remove(_group);
|
||||
}
|
||||
147
modules/panels/sovereignty.js
Normal file
147
modules/panels/sovereignty.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// modules/panels/sovereignty.js — Sovereignty Meter holographic arc gauge
|
||||
// Floating arc gauge above the platform showing the current sovereignty score.
|
||||
// Reads from state.sovereignty (populated by data/loaders.js via sovereignty-status.json).
|
||||
// The assessment is MANUAL — the panel always labels itself as such.
|
||||
//
|
||||
// Data category: REAL (manual assessment)
|
||||
// Data source: state.sovereignty (sovereignty-status.json via data/loaders.js)
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { state } from '../core/state.js';
|
||||
import { NEXUS } from '../core/theme.js';
|
||||
import { subscribe } from '../core/ticker.js';
|
||||
|
||||
const FONT = NEXUS.theme.fontMono;
|
||||
|
||||
// Defaults shown before data loads
|
||||
let _score = 85;
|
||||
let _label = 'Mostly Sovereign';
|
||||
let _assessmentType = 'MANUAL';
|
||||
|
||||
let _group, _arcMesh, _arcMat, _light, _spriteMat, _scene;
|
||||
let _lastSovereignty = null;
|
||||
|
||||
function _scoreColor(score) {
|
||||
if (score >= 80) return NEXUS.theme.sovereignHighHex;
|
||||
if (score >= 40) return NEXUS.theme.sovereignMidHex;
|
||||
return NEXUS.theme.sovereignLowHex;
|
||||
}
|
||||
|
||||
function _scoreColorStr(score) {
|
||||
if (score >= 80) return NEXUS.theme.sovereignHigh;
|
||||
if (score >= 40) return NEXUS.theme.sovereignMid;
|
||||
return NEXUS.theme.sovereignLow;
|
||||
}
|
||||
|
||||
function _buildArcGeo(score) {
|
||||
return new THREE.TorusGeometry(1.6, 0.1, 8, 64, (score / 100) * Math.PI * 2);
|
||||
}
|
||||
|
||||
function _buildMeterTexture(score, label, assessmentType) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const col = _scoreColorStr(score);
|
||||
|
||||
ctx.clearRect(0, 0, 256, 128);
|
||||
|
||||
ctx.font = `bold 52px ${FONT}`;
|
||||
ctx.fillStyle = col;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`${score}%`, 128, 50);
|
||||
|
||||
ctx.font = `16px ${FONT}`;
|
||||
ctx.fillStyle = '#8899bb';
|
||||
ctx.fillText(label.toUpperCase(), 128, 74);
|
||||
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.fillStyle = '#445566';
|
||||
ctx.fillText('SOVEREIGNTY', 128, 94);
|
||||
|
||||
ctx.font = `9px ${FONT}`;
|
||||
ctx.fillStyle = '#334455';
|
||||
ctx.fillText('MANUAL ASSESSMENT', 128, 112);
|
||||
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
function _applyScore(score, label, assessmentType) {
|
||||
_score = score;
|
||||
_label = label;
|
||||
_assessmentType = assessmentType;
|
||||
|
||||
_arcMesh.geometry.dispose();
|
||||
_arcMesh.geometry = _buildArcGeo(score);
|
||||
|
||||
const col = _scoreColor(score);
|
||||
_arcMat.color.setHex(col);
|
||||
_light.color.setHex(col);
|
||||
|
||||
if (_spriteMat.map) _spriteMat.map.dispose();
|
||||
_spriteMat.map = _buildMeterTexture(score, label, assessmentType);
|
||||
_spriteMat.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** @param {THREE.Scene} scene */
|
||||
export function init(scene) {
|
||||
_scene = scene;
|
||||
|
||||
_group = new THREE.Group();
|
||||
_group.position.set(0, 3.8, 0);
|
||||
|
||||
// Background ring
|
||||
const bgMat = new THREE.MeshBasicMaterial({ color: 0x0a1828, transparent: true, opacity: 0.5 });
|
||||
_group.add(new THREE.Mesh(new THREE.TorusGeometry(1.6, 0.1, 8, 64), bgMat));
|
||||
|
||||
// Score arc
|
||||
_arcMat = new THREE.MeshBasicMaterial({
|
||||
color: _scoreColor(_score),
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
_arcMesh = new THREE.Mesh(_buildArcGeo(_score), _arcMat);
|
||||
_arcMesh.rotation.z = Math.PI / 2; // arc starts at 12 o'clock
|
||||
_group.add(_arcMesh);
|
||||
|
||||
// Glow light
|
||||
_light = new THREE.PointLight(_scoreColor(_score), 0.7, 6);
|
||||
_group.add(_light);
|
||||
|
||||
// Sprite label
|
||||
_spriteMat = new THREE.SpriteMaterial({
|
||||
map: _buildMeterTexture(_score, _label, _assessmentType),
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(_spriteMat);
|
||||
sprite.scale.set(3.2, 1.6, 1);
|
||||
_group.add(sprite);
|
||||
|
||||
scene.add(_group);
|
||||
_group.traverse(obj => {
|
||||
if (obj.isMesh || obj.isSprite) obj.userData.zoomLabel = 'Sovereignty Meter';
|
||||
});
|
||||
|
||||
subscribe(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} _elapsed
|
||||
* @param {number} _delta
|
||||
*/
|
||||
export function update(_elapsed, _delta) {
|
||||
if (state.sovereignty && state.sovereignty !== _lastSovereignty) {
|
||||
const { score, label, assessment_type } = state.sovereignty;
|
||||
const s = Math.max(0, Math.min(100, typeof score === 'number' ? score : _score));
|
||||
const l = typeof label === 'string' ? label : _label;
|
||||
const t = typeof assessment_type === 'string' ? assessment_type : 'MANUAL';
|
||||
_applyScore(s, l, t);
|
||||
_lastSovereignty = state.sovereignty;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
if (_group) _scene.remove(_group);
|
||||
if (_spriteMat.map) _spriteMat.map.dispose();
|
||||
}
|
||||
110
nginx.conf
Normal file
110
nginx.conf
Normal file
@@ -0,0 +1,110 @@
|
||||
# nginx.conf — the-nexus.alexanderwhitestone.com
|
||||
#
|
||||
# DNS SETUP:
|
||||
# Add an A record pointing the-nexus.alexanderwhitestone.com → <VPS_IP>
|
||||
# Then obtain a TLS cert with Let's Encrypt:
|
||||
# certbot certonly --nginx -d the-nexus.alexanderwhitestone.com
|
||||
#
|
||||
# INSTALL:
|
||||
# sudo cp nginx.conf /etc/nginx/sites-available/the-nexus
|
||||
# sudo ln -sf /etc/nginx/sites-available/the-nexus /etc/nginx/sites-enabled/the-nexus
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# ── HTTP → HTTPS redirect ────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name the-nexus.alexanderwhitestone.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# ── HTTPS ────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name the-nexus.alexanderwhitestone.com;
|
||||
|
||||
# TLS — managed by Certbot; update paths if cert lives elsewhere
|
||||
ssl_certificate /etc/letsencrypt/live/the-nexus.alexanderwhitestone.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/the-nexus.alexanderwhitestone.com/privkey.pem;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
# ── gzip ─────────────────────────────────────────────────────────────────
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/wasm
|
||||
image/svg+xml
|
||||
font/woff
|
||||
font/woff2;
|
||||
|
||||
# ── Health check endpoint ────────────────────────────────────────────────
|
||||
# Simple endpoint for uptime monitoring.
|
||||
location /health {
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# ── WebSocket proxy (/ws) ─────────────────────────────────────────────────
|
||||
# Forwards to the Hermes / presence backend running on port 8080.
|
||||
# Adjust the upstream address if the WS server lives elsewhere.
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# ── Static files — proxied to nexus-main Docker container ────────────────
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4200;
|
||||
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;
|
||||
|
||||
# Long-lived cache for hashed/versioned assets
|
||||
location ~* \.(js|css|woff2?|ttf|otf|eot|svg|ico|png|jpg|jpeg|gif|webp|avif|wasm)$ {
|
||||
proxy_pass http://127.0.0.1:4200;
|
||||
proxy_set_header Host $host;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# index.html must always be revalidated
|
||||
location = /index.html {
|
||||
proxy_pass http://127.0.0.1:4200;
|
||||
proxy_set_header Host $host;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
}
|
||||
}
|
||||
7
package.json
Normal file
7
package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "the-nexus",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "Timmy's Sovereign Home — Three.js 3D world",
|
||||
"private": true
|
||||
}
|
||||
44
portals.json
Normal file
44
portals.json
Normal file
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": "morrowind",
|
||||
"name": "Morrowind",
|
||||
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
|
||||
"status": "offline",
|
||||
"color": "#ff6600",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"destination": {
|
||||
"url": "https://morrowind.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "bannerlord",
|
||||
"name": "Bannerlord",
|
||||
"description": "Calradia battle harness. Massive armies, tactical command.",
|
||||
"status": "offline",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"destination": {
|
||||
"url": "https://bannerlord.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "world": "calradia" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workshop",
|
||||
"name": "Workshop",
|
||||
"description": "The creative harness. Build, script, and manifest.",
|
||||
"status": "offline",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"destination": {
|
||||
"url": "https://workshop.timmy.foundation",
|
||||
"type": "harness",
|
||||
"params": { "mode": "creative" }
|
||||
}
|
||||
}
|
||||
]
|
||||
4
sovereignty-status.json
Normal file
4
sovereignty-status.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"score": 75,
|
||||
"label": "Stable"
|
||||
}
|
||||
860
style.css
860
style.css
@@ -1,361 +1,599 @@
|
||||
/* === NEXUS DESIGN SYSTEM === */
|
||||
/* === DESIGN SYSTEM — NEXUS === */
|
||||
:root {
|
||||
--font-display: 'Orbitron', sans-serif;
|
||||
--font-body: 'JetBrains Mono', monospace;
|
||||
|
||||
--color-bg: #050510;
|
||||
--color-surface: rgba(10, 15, 40, 0.85);
|
||||
--color-border: rgba(74, 240, 192, 0.2);
|
||||
--color-border-bright: rgba(74, 240, 192, 0.5);
|
||||
|
||||
--color-text: #c8d8e8;
|
||||
--color-text-muted: #5a6a8a;
|
||||
--color-text-bright: #e0f0ff;
|
||||
|
||||
--color-primary: #4af0c0;
|
||||
--color-primary-dim: rgba(74, 240, 192, 0.3);
|
||||
--color-secondary: #7b5cff;
|
||||
--color-danger: #ff4466;
|
||||
--color-warning: #ffaa22;
|
||||
--color-gold: #ffd700;
|
||||
|
||||
--text-xs: 11px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 15px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 24px;
|
||||
--text-2xl: 36px;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
--panel-blur: 16px;
|
||||
--panel-radius: 8px;
|
||||
--transition-ui: 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--color-bg: #000008;
|
||||
--color-primary: #4488ff;
|
||||
--color-secondary: #334488;
|
||||
--color-text: #ccd6f6;
|
||||
--color-text-muted: #4a5568;
|
||||
--font-body: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
canvas#nexus-canvas {
|
||||
display: block;
|
||||
font-family: var(--font-body);
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* === LOADING SCREEN === */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
#loading-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.loader-content {
|
||||
text-align: center;
|
||||
}
|
||||
.loader-sigil {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.loader-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3em;
|
||||
color: var(--color-primary);
|
||||
text-shadow: 0 0 30px rgba(74, 240, 192, 0.4);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.loader-subtitle {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.loader-bar {
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: rgba(74, 240, 192, 0.15);
|
||||
border-radius: 1px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.loader-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
|
||||
border-radius: 1px;
|
||||
transition: width 0.3s ease;
|
||||
/* Matrix rain sits behind the Three.js renderer */
|
||||
#matrix-rain {
|
||||
z-index: 0;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
/* === ENTER PROMPT === */
|
||||
#enter-prompt {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgba(5, 5, 16, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#enter-prompt.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.enter-content {
|
||||
text-align: center;
|
||||
}
|
||||
.enter-content h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 0.2em;
|
||||
text-shadow: 0 0 20px rgba(74, 240, 192, 0.3);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.enter-content p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
animation: pulse-text 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse-text {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* === GAME UI (HUD) === */
|
||||
.game-ui {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
/* === HUD === */
|
||||
.hud-controls {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* === AUDIO TOGGLE === */
|
||||
#audio-toggle {
|
||||
font-size: 14px;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#audio-toggle:hover {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
#podcast-toggle {
|
||||
margin-left: 8px;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#podcast-toggle.active {
|
||||
background-color: #0066cc;
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
#podcast-toggle:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#soul-toggle {
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.game-ui button, .game-ui input, .game-ui [data-interactive] {
|
||||
pointer-events: auto;
|
||||
|
||||
#audio-toggle.muted {
|
||||
background-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Debug overlay */
|
||||
.hud-debug {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
left: var(--space-3);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #0f0;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
/* === DEBUG MODE === */
|
||||
#debug-toggle {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* === SESSION EXPORT === */
|
||||
#export-session {
|
||||
margin-left: 8px;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
white-space: pre;
|
||||
pointer-events: none;
|
||||
font-variant-numeric: tabular-nums lining-nums;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Location indicator */
|
||||
.hud-location {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
#podcast-toggle {
|
||||
margin-left: 8px;
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
#podcast-toggle.active {
|
||||
background-color: #0066cc;
|
||||
}
|
||||
|
||||
#podcast-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#podcast-error {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-primary);
|
||||
text-shadow: 0 0 10px rgba(74, 240, 192, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background: rgba(255, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
.hud-location-icon {
|
||||
font-size: 16px;
|
||||
animation: spin-slow 10s linear infinite;
|
||||
}
|
||||
@keyframes spin-slow {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
#podcast-toggle:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Controls hint */
|
||||
.hud-controls {
|
||||
position: absolute;
|
||||
bottom: var(--space-3);
|
||||
left: var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
#podcast-toggle:hover {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
#export-session:hover {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.collision-box {
|
||||
outline: 2px solid red;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light-source {
|
||||
outline: 2px dashed yellow;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* === OVERVIEW MODE === */
|
||||
#overview-indicator {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hud-controls span {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 8, 0.6);
|
||||
white-space: nowrap;
|
||||
animation: overview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* === CHAT PANEL === */
|
||||
.chat-panel {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
width: 380px;
|
||||
max-height: 400px;
|
||||
background: var(--color-surface);
|
||||
backdrop-filter: blur(var(--panel-blur));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--panel-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
transition: max-height var(--transition-ui);
|
||||
#overview-indicator.visible {
|
||||
display: block;
|
||||
}
|
||||
.chat-panel.collapsed {
|
||||
max-height: 42px;
|
||||
|
||||
.overview-hint {
|
||||
margin-left: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-bright);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 6px var(--color-primary);
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
|
||||
@keyframes overview-pulse {
|
||||
0%, 100% { opacity: 0.7; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.chat-toggle-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-ui);
|
||||
}
|
||||
.chat-panel.collapsed .chat-toggle-btn {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-height: 280px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(74,240,192,0.2) transparent;
|
||||
}
|
||||
.chat-msg {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
.chat-msg-prefix {
|
||||
font-weight: 700;
|
||||
}
|
||||
.chat-msg-system .chat-msg-prefix { color: var(--color-text-muted); }
|
||||
.chat-msg-timmy .chat-msg-prefix { color: var(--color-primary); }
|
||||
.chat-msg-user .chat-msg-prefix { color: var(--color-gold); }
|
||||
.chat-msg-error .chat-msg-prefix { color: var(--color-danger); }
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-bright);
|
||||
outline: none;
|
||||
}
|
||||
.chat-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.chat-send-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--color-primary);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-ui);
|
||||
}
|
||||
.chat-send-btn:hover {
|
||||
background: rgba(74, 240, 192, 0.1);
|
||||
/* === PHOTO MODE === */
|
||||
body.photo-mode .hud-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* === FOOTER === */
|
||||
.nexus-footer {
|
||||
body.photo-mode #overview-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#photo-indicator {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: var(--space-1);
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
font-size: 10px;
|
||||
opacity: 0.3;
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 4px 12px;
|
||||
background: rgba(0, 0, 8, 0.5);
|
||||
white-space: nowrap;
|
||||
animation: overview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.nexus-footer a {
|
||||
|
||||
#photo-indicator.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-hint {
|
||||
margin-left: 12px;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.nexus-footer a:hover {
|
||||
|
||||
#photo-focus {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.chat-panel {
|
||||
width: calc(100vw - 32px);
|
||||
right: var(--space-4);
|
||||
bottom: var(--space-4);
|
||||
}
|
||||
.hud-controls {
|
||||
display: none;
|
||||
}
|
||||
/* === ZOOM-TO-OBJECT INDICATOR === */
|
||||
#zoom-indicator {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--color-accent);
|
||||
padding: 4px 12px;
|
||||
background: rgba(0, 0, 8, 0.6);
|
||||
white-space: nowrap;
|
||||
animation: overview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#zoom-indicator.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
margin-left: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* === WEATHER HUD === */
|
||||
#weather-hud {
|
||||
position: fixed;
|
||||
bottom: 14px;
|
||||
left: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(0, 6, 20, 0.72);
|
||||
border: 1px solid rgba(68, 136, 255, 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
#weather-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#weather-temp {
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
#weather-desc {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* === SOVEREIGNTY EASTER EGG === */
|
||||
#sovereignty-msg {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #ffd700;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 30;
|
||||
border: 1px solid #ffd700;
|
||||
padding: 8px 20px;
|
||||
background: rgba(0, 0, 8, 0.7);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#sovereignty-msg.visible {
|
||||
display: block;
|
||||
animation: sovereignty-flash 2.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes sovereignty-flash {
|
||||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.85); }
|
||||
15% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
|
||||
40% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) scale(1); }
|
||||
}
|
||||
|
||||
/* === BITCOIN BLOCK HEIGHT === */
|
||||
#block-height-display {
|
||||
position: fixed;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
z-index: 20;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-primary);
|
||||
background: rgba(0, 0, 8, 0.7);
|
||||
border: 1px solid var(--color-secondary);
|
||||
padding: 4px 10px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-height-label {
|
||||
color: var(--color-text-muted);
|
||||
margin-right: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#block-height-value {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
#block-height-display.fresh #block-height-value {
|
||||
animation: block-flash 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes block-flash {
|
||||
0% { color: #ffffff; text-shadow: 0 0 8px #4488ff; }
|
||||
100% { color: var(--color-primary); text-shadow: none; }
|
||||
}
|
||||
|
||||
/* === CRT / CYBERPUNK OVERLAY === */
|
||||
.crt-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%),
|
||||
linear-gradient(90deg, rgba(255, 0, 0, 0.04), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.04));
|
||||
background-size: 100% 4px, 4px 100%;
|
||||
animation: flicker 0.15s infinite;
|
||||
box-shadow: inset 0 0 100px rgba(0,0,0,0.9);
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
0% { opacity: 0.95; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.98; }
|
||||
}
|
||||
|
||||
.crt-overlay::after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: rgba(18, 16, 16, 0.1);
|
||||
opacity: 0;
|
||||
z-index: 999;
|
||||
pointer-events: none;
|
||||
animation: crt-pulse 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes crt-pulse {
|
||||
0% { opacity: 0.05; }
|
||||
50% { opacity: 0.15; }
|
||||
100% { opacity: 0.05; }
|
||||
}
|
||||
|
||||
/* === THE OATH OVERLAY === */
|
||||
#oath-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: rgba(0, 0, 8, 0.82);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#oath-overlay.visible {
|
||||
display: flex;
|
||||
animation: oath-fade-in 1.2s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes oath-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
#oath-inner {
|
||||
max-width: 560px;
|
||||
width: 90%;
|
||||
padding: 40px 48px;
|
||||
border: 1px solid #ffd700;
|
||||
box-shadow: 0 0 60px rgba(255, 215, 0, 0.15), inset 0 0 40px rgba(255, 215, 0, 0.04);
|
||||
background: rgba(0, 4, 16, 0.9);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#oath-inner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border: 1px solid rgba(255, 215, 0, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#oath-title {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5em;
|
||||
text-transform: uppercase;
|
||||
color: #ffd700;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#oath-text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
color: #e8e8f8;
|
||||
min-height: 220px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#oath-text .oath-line {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
animation: oath-line-in 0.6s ease forwards;
|
||||
}
|
||||
|
||||
#oath-text .oath-line.blank {
|
||||
height: 0.8em;
|
||||
}
|
||||
|
||||
@keyframes oath-line-in {
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
#oath-hint {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* === TIME-LAPSE MODE === */
|
||||
#timelapse-indicator {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 44px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #00ffcc;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
border: 1px solid #00ffcc;
|
||||
padding: 6px 14px 8px;
|
||||
background: rgba(0, 8, 24, 0.85);
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#timelapse-indicator.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
animation: timelapse-glow 1.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes timelapse-glow {
|
||||
from { box-shadow: 0 0 6px rgba(0, 255, 204, 0.3); }
|
||||
to { box-shadow: 0 0 16px rgba(0, 255, 204, 0.75); }
|
||||
}
|
||||
|
||||
.timelapse-label {
|
||||
color: #00ffcc;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#timelapse-clock {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.timelapse-track {
|
||||
width: 110px;
|
||||
height: 4px;
|
||||
background: rgba(0, 255, 204, 0.18);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#timelapse-bar {
|
||||
height: 100%;
|
||||
background: #00ffcc;
|
||||
border-radius: 2px;
|
||||
width: 0%;
|
||||
transition: width 0.12s linear;
|
||||
}
|
||||
|
||||
.timelapse-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
#timelapse-btn {
|
||||
margin-left: 8px;
|
||||
background-color: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-body);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#timelapse-btn:hover {
|
||||
background-color: #00664433;
|
||||
color: #00ffcc;
|
||||
}
|
||||
|
||||
#timelapse-btn.active {
|
||||
background-color: rgba(0, 255, 204, 0.15);
|
||||
color: #00ffcc;
|
||||
border: 1px solid #00ffcc;
|
||||
}
|
||||
|
||||
96
sw.js
Normal file
96
sw.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// The Nexus — Service Worker
|
||||
// Cache-first for assets, network-first for API calls
|
||||
|
||||
const CACHE_NAME = 'nexus-v1';
|
||||
const ASSET_CACHE = 'nexus-assets-v1';
|
||||
|
||||
const CORE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/style.css',
|
||||
'/manifest.json',
|
||||
'/ws-client.js',
|
||||
'https://unpkg.com/three@0.183.0/build/three.module.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/controls/OrbitControls.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/EffectComposer.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/RenderPass.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/UnrealBloomPass.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/postprocessing/ShaderPass.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/shaders/CopyShader.js',
|
||||
'https://unpkg.com/three@0.183.0/examples/jsm/shaders/LuminosityHighPassShader.js',
|
||||
];
|
||||
|
||||
// Install: precache core assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(ASSET_CACHE).then((cache) => cache.addAll(CORE_ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME && key !== ASSET_CACHE)
|
||||
.map((key) => caches.delete(key))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Network-first for API calls (Gitea / WebSocket upgrades / portals.json live data)
|
||||
if (
|
||||
url.pathname.startsWith('/api/') ||
|
||||
url.hostname.includes('143.198.27.163') ||
|
||||
request.headers.get('Upgrade') === 'websocket'
|
||||
) {
|
||||
event.respondWith(networkFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for everything else (local assets + CDN)
|
||||
event.respondWith(cacheFirst(request));
|
||||
});
|
||||
|
||||
async function cacheFirst(request) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(ASSET_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Offline and not cached — return a minimal fallback for navigation
|
||||
if (request.mode === 'navigate') {
|
||||
const fallback = await caches.match('/index.html');
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
return cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||
}
|
||||
}
|
||||
241
test-hermes-session.js
Normal file
241
test-hermes-session.js
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Integration test — Hermes session save and load
|
||||
*
|
||||
* Tests the session persistence layer of WebSocketClient in isolation.
|
||||
* Runs with Node.js built-ins only — no browser, no real WebSocket.
|
||||
*
|
||||
* Run: node test-hermes-session.js
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function pass(name) {
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
|
||||
function fail(name, reason) {
|
||||
console.log(` ✗ ${name}`);
|
||||
if (reason) console.log(` → ${reason}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
function section(name) {
|
||||
console.log(`\n${name}`);
|
||||
}
|
||||
|
||||
// ── In-memory localStorage mock ─────────────────────────────────────────────
|
||||
|
||||
class MockStorage {
|
||||
constructor() { this._store = new Map(); }
|
||||
getItem(key) { return this._store.has(key) ? this._store.get(key) : null; }
|
||||
setItem(key, value) { this._store.set(key, String(value)); }
|
||||
removeItem(key) { this._store.delete(key); }
|
||||
clear() { this._store.clear(); }
|
||||
}
|
||||
|
||||
// ── Minimal WebSocketClient extracted from ws-client.js ───────────────────
|
||||
// We re-implement only the session methods so the test has no browser deps.
|
||||
|
||||
const SESSION_STORAGE_KEY = 'hermes-session';
|
||||
|
||||
class SessionClient {
|
||||
constructor(storage) {
|
||||
this._storage = storage;
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
saveSession(data) {
|
||||
const payload = { ...data, savedAt: Date.now() };
|
||||
this._storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload));
|
||||
this.session = data;
|
||||
}
|
||||
|
||||
loadSession() {
|
||||
const raw = this._storage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
this.session = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
clearSession() {
|
||||
this._storage.removeItem(SESSION_STORAGE_KEY);
|
||||
this.session = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
section('Session Save');
|
||||
|
||||
const store1 = new MockStorage();
|
||||
const client1 = new SessionClient(store1);
|
||||
|
||||
// saveSession persists to storage
|
||||
client1.saveSession({ token: 'abc-123', clientId: 'nexus-visitor' });
|
||||
const raw = store1.getItem(SESSION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
pass('saveSession writes to storage');
|
||||
} else {
|
||||
fail('saveSession writes to storage', 'storage item is null after save');
|
||||
}
|
||||
|
||||
// Persisted JSON is parseable
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
pass('stored value is valid JSON');
|
||||
|
||||
if (parsed.token === 'abc-123') {
|
||||
pass('token field preserved');
|
||||
} else {
|
||||
fail('token field preserved', `expected "abc-123", got "${parsed.token}"`);
|
||||
}
|
||||
|
||||
if (parsed.clientId === 'nexus-visitor') {
|
||||
pass('clientId field preserved');
|
||||
} else {
|
||||
fail('clientId field preserved', `expected "nexus-visitor", got "${parsed.clientId}"`);
|
||||
}
|
||||
|
||||
if (typeof parsed.savedAt === 'number' && parsed.savedAt > 0) {
|
||||
pass('savedAt timestamp present');
|
||||
} else {
|
||||
fail('savedAt timestamp present', `got: ${parsed.savedAt}`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail('stored value is valid JSON', e.message);
|
||||
}
|
||||
|
||||
// in-memory session property updated
|
||||
if (client1.session && client1.session.token === 'abc-123') {
|
||||
pass('this.session updated after saveSession');
|
||||
} else {
|
||||
fail('this.session updated after saveSession', JSON.stringify(client1.session));
|
||||
}
|
||||
|
||||
// ── Session Load ─────────────────────────────────────────────────────────────
|
||||
section('Session Load');
|
||||
|
||||
const store2 = new MockStorage();
|
||||
const client2 = new SessionClient(store2);
|
||||
|
||||
// loadSession on empty storage returns null
|
||||
const empty = client2.loadSession();
|
||||
if (empty === null) {
|
||||
pass('loadSession returns null when no session stored');
|
||||
} else {
|
||||
fail('loadSession returns null when no session stored', `got: ${JSON.stringify(empty)}`);
|
||||
}
|
||||
|
||||
// Seed the storage and load
|
||||
store2.setItem(SESSION_STORAGE_KEY, JSON.stringify({ token: 'xyz-789', clientId: 'timmy', savedAt: 1700000000000 }));
|
||||
const loaded = client2.loadSession();
|
||||
if (loaded && loaded.token === 'xyz-789') {
|
||||
pass('loadSession returns stored token');
|
||||
} else {
|
||||
fail('loadSession returns stored token', `got: ${JSON.stringify(loaded)}`);
|
||||
}
|
||||
|
||||
if (loaded && loaded.clientId === 'timmy') {
|
||||
pass('loadSession returns stored clientId');
|
||||
} else {
|
||||
fail('loadSession returns stored clientId', `got: ${JSON.stringify(loaded)}`);
|
||||
}
|
||||
|
||||
if (client2.session && client2.session.token === 'xyz-789') {
|
||||
pass('this.session updated after loadSession');
|
||||
} else {
|
||||
fail('this.session updated after loadSession', JSON.stringify(client2.session));
|
||||
}
|
||||
|
||||
// ── Full save → reload cycle ─────────────────────────────────────────────────
|
||||
section('Save → Load Round-trip');
|
||||
|
||||
const store3 = new MockStorage();
|
||||
const writer = new SessionClient(store3);
|
||||
const reader = new SessionClient(store3); // simulates a page reload (new instance, same storage)
|
||||
|
||||
writer.saveSession({ token: 'round-trip-token', role: 'visitor' });
|
||||
|
||||
const reloaded = reader.loadSession();
|
||||
if (reloaded && reloaded.token === 'round-trip-token') {
|
||||
pass('round-trip: token survives save → load');
|
||||
} else {
|
||||
fail('round-trip: token survives save → load', JSON.stringify(reloaded));
|
||||
}
|
||||
|
||||
if (reloaded && reloaded.role === 'visitor') {
|
||||
pass('round-trip: extra fields survive save → load');
|
||||
} else {
|
||||
fail('round-trip: extra fields survive save → load', JSON.stringify(reloaded));
|
||||
}
|
||||
|
||||
// ── clearSession ─────────────────────────────────────────────────────────────
|
||||
section('Session Clear');
|
||||
|
||||
const store4 = new MockStorage();
|
||||
const client4 = new SessionClient(store4);
|
||||
|
||||
client4.saveSession({ token: 'to-be-cleared' });
|
||||
client4.clearSession();
|
||||
|
||||
const afterClear = client4.loadSession();
|
||||
if (afterClear === null) {
|
||||
pass('clearSession removes stored session');
|
||||
} else {
|
||||
fail('clearSession removes stored session', `still got: ${JSON.stringify(afterClear)}`);
|
||||
}
|
||||
|
||||
if (client4.session === null) {
|
||||
pass('this.session is null after clearSession');
|
||||
} else {
|
||||
fail('this.session is null after clearSession', JSON.stringify(client4.session));
|
||||
}
|
||||
|
||||
// ── ws-client.js static check ────────────────────────────────────────────────
|
||||
section('ws-client.js Session Methods (static analysis)');
|
||||
|
||||
const wsClientSrc = (() => {
|
||||
try { return readFileSync(resolve(__dirname, 'ws-client.js'), 'utf8'); }
|
||||
catch (e) { fail('ws-client.js readable', e.message); return ''; }
|
||||
})();
|
||||
|
||||
if (wsClientSrc) {
|
||||
const checks = [
|
||||
['saveSession method defined', /saveSession\s*\(/],
|
||||
['loadSession method defined', /loadSession\s*\(/],
|
||||
['clearSession method defined', /clearSession\s*\(/],
|
||||
['SESSION_STORAGE_KEY constant', /SESSION_STORAGE_KEY/],
|
||||
['session-init message handled', /'session-init'/],
|
||||
['session-resume sent on open', /session-resume/],
|
||||
['this.session property set', /this\.session\s*=/],
|
||||
];
|
||||
|
||||
for (const [name, re] of checks) {
|
||||
if (re.test(wsClientSrc)) {
|
||||
pass(name);
|
||||
} else {
|
||||
fail(name, `pattern not found: ${re}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nSome tests failed. Fix the issues above before committing.\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nAll session tests passed.\n');
|
||||
}
|
||||
150
test.js
Normal file
150
test.js
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Nexus Test Harness
|
||||
* Validates the scene loads without errors using only Node.js built-ins.
|
||||
* Run: node test.js
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, statSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function pass(name) {
|
||||
console.log(` ✓ ${name}`);
|
||||
passed++;
|
||||
}
|
||||
|
||||
function fail(name, reason) {
|
||||
console.log(` ✗ ${name}`);
|
||||
if (reason) console.log(` → ${reason}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
function section(name) {
|
||||
console.log(`\n${name}`);
|
||||
}
|
||||
|
||||
// ── Syntax checks ──────────────────────────────────────────────────────────
|
||||
section('JS Syntax');
|
||||
|
||||
for (const file of ['app.js', 'ws-client.js']) {
|
||||
try {
|
||||
execSync(`node --check ${resolve(__dirname, file)}`, { stdio: 'pipe' });
|
||||
pass(`${file} parses without syntax errors`);
|
||||
} catch (e) {
|
||||
fail(`${file} syntax check`, e.stderr?.toString().trim() || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── File size budget ────────────────────────────────────────────────────────
|
||||
section('File Size Budget (< 500 KB)');
|
||||
|
||||
for (const file of ['app.js', 'ws-client.js']) {
|
||||
try {
|
||||
const bytes = statSync(resolve(__dirname, file)).size;
|
||||
const kb = (bytes / 1024).toFixed(1);
|
||||
if (bytes < 500 * 1024) {
|
||||
pass(`${file} is ${kb} KB`);
|
||||
} else {
|
||||
fail(`${file} exceeds 500 KB budget`, `${kb} KB`);
|
||||
}
|
||||
} catch (e) {
|
||||
fail(`${file} size check`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON validation ─────────────────────────────────────────────────────────
|
||||
section('JSON Files');
|
||||
|
||||
for (const file of ['manifest.json', 'portals.json', 'vision.json']) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(__dirname, file), 'utf8');
|
||||
JSON.parse(raw);
|
||||
pass(`${file} is valid JSON`);
|
||||
} catch (e) {
|
||||
fail(`${file}`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML structure ──────────────────────────────────────────────────────────
|
||||
section('HTML Structure (index.html)');
|
||||
|
||||
const html = (() => {
|
||||
try { return readFileSync(resolve(__dirname, 'index.html'), 'utf8'); }
|
||||
catch (e) { fail('index.html readable', e.message); return ''; }
|
||||
})();
|
||||
|
||||
if (html) {
|
||||
const checks = [
|
||||
['DOCTYPE declaration', /<!DOCTYPE html>/i],
|
||||
['<html lang> attribute', /<html[^>]+lang=/i],
|
||||
['charset meta tag', /<meta[^>]+charset/i],
|
||||
['viewport meta tag', /<meta[^>]+viewport/i],
|
||||
['<title> tag', /<title>[^<]+<\/title>/i],
|
||||
['importmap script', /<script[^>]+type="importmap"/i],
|
||||
['three.js in importmap', /"three"\s*:/],
|
||||
['app.js module script', /<script[^>]+type="module"[^>]+src="app\.js"/i],
|
||||
['debug-toggle element', /id="debug-toggle"/],
|
||||
['</html> closing tag', /<\/html>/i],
|
||||
];
|
||||
|
||||
for (const [name, re] of checks) {
|
||||
if (re.test(html)) {
|
||||
pass(name);
|
||||
} else {
|
||||
fail(name, `pattern not found: ${re}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── app.js static analysis ──────────────────────────────────────────────────
|
||||
section('app.js Scene Components');
|
||||
|
||||
const appJs = (() => {
|
||||
try { return readFileSync(resolve(__dirname, 'app.js'), 'utf8'); }
|
||||
catch (e) { fail('app.js readable', e.message); return ''; }
|
||||
})();
|
||||
|
||||
if (appJs) {
|
||||
const checks = [
|
||||
['NEXUS.colors palette defined', /const NEXUS\s*=\s*\{/],
|
||||
['THREE.Scene created', /new THREE\.Scene\(\)/],
|
||||
['THREE.PerspectiveCamera created', /new THREE\.PerspectiveCamera\(/],
|
||||
['THREE.WebGLRenderer created', /new THREE\.WebGLRenderer\(/],
|
||||
['renderer appended to DOM', /document\.body\.appendChild\(renderer\.domElement\)/],
|
||||
['animate function defined', /function animate\s*\(\)/],
|
||||
['requestAnimationFrame called', /requestAnimationFrame\(animate\)/],
|
||||
['renderer.render called', /renderer\.render\(scene,\s*camera\)/],
|
||||
['resize handler registered', /addEventListener\(['"]resize['"]/],
|
||||
['clock defined', /new THREE\.Clock\(\)/],
|
||||
['star field created', /new THREE\.Points\(/],
|
||||
['constellation lines built', /buildConstellationLines/],
|
||||
['ws-client imported', /import.*ws-client/],
|
||||
['wsClient.connect called', /wsClient\.connect\(\)/],
|
||||
];
|
||||
|
||||
for (const [name, re] of checks) {
|
||||
if (re.test(appJs)) {
|
||||
pass(name);
|
||||
} else {
|
||||
fail(name, `pattern not found: ${re}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${'─'.repeat(50)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nSome tests failed. Fix the issues above before committing.\n');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nAll tests passed.\n');
|
||||
}
|
||||
288
ws-client.js
Normal file
288
ws-client.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* ws-client.js — Hermes Gateway WebSocket Client
|
||||
*
|
||||
* Manages the persistent WebSocket connection between the Nexus (browser) and
|
||||
* the Hermes agent gateway. Hermes is the sovereign orchestration layer that
|
||||
* routes AI provider responses, Gitea PR events, visitor presence, and chat
|
||||
* messages into the 3D world.
|
||||
*
|
||||
* ## Provider Fallback Chain
|
||||
*
|
||||
* The Hermes gateway itself manages provider selection (Claude → Gemini →
|
||||
* Perplexity → fallback). From the Nexus client's perspective, all providers
|
||||
* arrive through the single WebSocket endpoint below. The client's
|
||||
* responsibility is to stay connected so no events are dropped.
|
||||
*
|
||||
* Connection lifecycle:
|
||||
*
|
||||
* 1. connect() — opens WebSocket to HERMES_WS_URL
|
||||
* 2. onopen — flushes any queued messages; fires 'ws-connected'
|
||||
* 3. onmessage — JSON-parses frames; dispatches typed CustomEvents
|
||||
* 4. onclose / onerror — fires 'ws-disconnected'; triggers _scheduleReconnect()
|
||||
* 5. _scheduleReconnect — exponential backoff (1s → 2s → 4s … ≤ 30s) up to
|
||||
* 10 attempts, then fires 'ws-failed' and gives up
|
||||
*
|
||||
* Message queue: messages sent while disconnected are buffered in
|
||||
* `this.messageQueue` and flushed on the next successful connection.
|
||||
*
|
||||
* ## Dispatched CustomEvents
|
||||
*
|
||||
* | type | CustomEvent name | Payload (event.detail) |
|
||||
* |-------------------|--------------------|------------------------------------|
|
||||
* | chat / chat-message | chat-message | { type, text, sender?, … } |
|
||||
* | status-update | status-update | { type, status, agent?, … } |
|
||||
* | pr-notification | pr-notification | { type, action, pr, … } |
|
||||
* | player-joined | player-joined | { type, id, name?, … } |
|
||||
* | player-left | player-left | { type, id, … } |
|
||||
* | (connection) | ws-connected | { url } |
|
||||
* | (connection) | ws-disconnected | { code } |
|
||||
* | (terminal) | ws-failed | — |
|
||||
*/
|
||||
|
||||
/** Primary Hermes gateway endpoint. */
|
||||
const HERMES_WS_URL = 'ws://143.198.27.163/api/world/ws';
|
||||
const SESSION_STORAGE_KEY = 'hermes-session';
|
||||
|
||||
/**
|
||||
* WebSocketClient — resilient WebSocket wrapper with exponential-backoff
|
||||
* reconnection and an outbound message queue.
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
/**
|
||||
* @param {string} [url] - WebSocket endpoint (defaults to HERMES_WS_URL)
|
||||
*/
|
||||
constructor(url = HERMES_WS_URL) {
|
||||
this.url = url;
|
||||
/** Number of reconnect attempts since last successful connection. */
|
||||
this.reconnectAttempts = 0;
|
||||
/** Hard cap on reconnect attempts before emitting 'ws-failed'. */
|
||||
this.maxReconnectAttempts = 10;
|
||||
/** Initial backoff delay in ms (doubles each attempt). */
|
||||
this.reconnectBaseDelay = 1000;
|
||||
/** Maximum backoff delay in ms. */
|
||||
this.maxReconnectDelay = 30000;
|
||||
/** @type {WebSocket|null} */
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
this.reconnectTimeout = null;
|
||||
/** Messages queued while disconnected; flushed on reconnect. */
|
||||
this.messageQueue = [];
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist session data to localStorage so it survives page reloads.
|
||||
* @param {Object} data Arbitrary session payload (token, id, etc.)
|
||||
*/
|
||||
saveSession(data) {
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify({ ...data, savedAt: Date.now() }));
|
||||
this.session = data;
|
||||
console.log('[hermes] Session saved');
|
||||
} catch (err) {
|
||||
console.warn('[hermes] Could not save session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session data from localStorage.
|
||||
* @returns {Object|null} Previously saved session, or null if none.
|
||||
*/
|
||||
loadSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw);
|
||||
this.session = data;
|
||||
console.log('[hermes] Session loaded (savedAt:', new Date(data.savedAt).toISOString(), ')');
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.warn('[hermes] Could not load session:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any persisted session from localStorage.
|
||||
*/
|
||||
clearSession() {
|
||||
try {
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
this.session = null;
|
||||
console.log('[hermes] Session cleared');
|
||||
} catch (err) {
|
||||
console.warn('[hermes] Could not clear session:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the WebSocket connection. No-ops if already open or connecting.
|
||||
*/
|
||||
connect() {
|
||||
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
} catch (err) {
|
||||
console.error('[hermes] WebSocket construction failed:', err);
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log('[hermes] Connected to Hermes gateway');
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
// Restore session if available; send it as the first frame so the server
|
||||
// can resume the previous session rather than creating a new one.
|
||||
const existing = this.loadSession();
|
||||
if (existing?.token) {
|
||||
this._send({ type: 'session-resume', token: existing.token });
|
||||
}
|
||||
this.messageQueue.forEach(msg => this._send(msg));
|
||||
this.messageQueue = [];
|
||||
window.dispatchEvent(new CustomEvent('ws-connected', { detail: { url: this.url } }));
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch (err) {
|
||||
console.warn('[hermes] Unparseable message:', event.data);
|
||||
return;
|
||||
}
|
||||
this._route(data);
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
this.connected = false;
|
||||
this.socket = null;
|
||||
console.warn(`[hermes] Connection closed (code=${event.code})`);
|
||||
window.dispatchEvent(new CustomEvent('ws-disconnected', { detail: { code: event.code } }));
|
||||
this._scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
// onclose fires after onerror; logging here would be redundant noise
|
||||
console.warn('[hermes] WebSocket error — waiting for close event');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an inbound Hermes message to the appropriate CustomEvent.
|
||||
* Unrecognised types are logged at debug level and dropped.
|
||||
*
|
||||
* @param {{ type: string, [key: string]: unknown }} data
|
||||
*/
|
||||
_route(data) {
|
||||
switch (data.type) {
|
||||
case 'session-init':
|
||||
// Server issued a new session token — persist it for future reconnects.
|
||||
if (data.token) {
|
||||
this.saveSession({ token: data.token, clientId: data.clientId });
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('session-init', { detail: data }));
|
||||
break;
|
||||
|
||||
case 'chat':
|
||||
case 'chat-message':
|
||||
window.dispatchEvent(new CustomEvent('chat-message', { detail: data }));
|
||||
break;
|
||||
|
||||
case 'status-update':
|
||||
window.dispatchEvent(new CustomEvent('status-update', { detail: data }));
|
||||
break;
|
||||
|
||||
case 'pr-notification':
|
||||
window.dispatchEvent(new CustomEvent('pr-notification', { detail: data }));
|
||||
break;
|
||||
|
||||
case 'player-joined':
|
||||
window.dispatchEvent(new CustomEvent('player-joined', { detail: data }));
|
||||
break;
|
||||
|
||||
case 'player-left':
|
||||
window.dispatchEvent(new CustomEvent('player-left', { detail: data }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.debug('[hermes] Unhandled message type:', data.type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next reconnect attempt using exponential backoff.
|
||||
*
|
||||
* Backoff schedule (base 1 s, cap 30 s):
|
||||
* attempt 1 → 1 s
|
||||
* attempt 2 → 2 s
|
||||
* attempt 3 → 4 s
|
||||
* attempt 4 → 8 s
|
||||
* attempt 5 → 16 s
|
||||
* attempt 6+ → 30 s (capped)
|
||||
*
|
||||
* After maxReconnectAttempts the client emits 'ws-failed' and stops trying.
|
||||
*/
|
||||
_scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.warn('[hermes] Max reconnection attempts reached — giving up');
|
||||
window.dispatchEvent(new CustomEvent('ws-failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
console.log(`[hermes] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})`);
|
||||
this.reconnectTimeout = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level send — caller must ensure socket is open.
|
||||
* @param {object} message
|
||||
*/
|
||||
_send(message) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to Hermes. If not currently connected the message is
|
||||
* buffered and will be delivered on the next successful connection.
|
||||
*
|
||||
* @param {object} message
|
||||
*/
|
||||
send(message) {
|
||||
if (this.connected && this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this._send(message);
|
||||
} else {
|
||||
this.messageQueue.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intentionally close the connection and cancel any pending reconnect.
|
||||
* After calling disconnect() the client will not attempt to reconnect.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
this.maxReconnectAttempts = 0; // prevent auto-reconnect after intentional disconnect
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared singleton WebSocket client — imported by app.js. */
|
||||
export const wsClient = new WebSocketClient();
|
||||
Reference in New Issue
Block a user