amerc suite: 2D pixel tavern + zero-dep auth/admin/docs/pm backend

- Scene2D pixel tavern (replaces three.js 3D scene)
- amerc-api: node:http+node:sqlite+node:crypto, auth/admin/docs/files/boards
- docs.amerc.ai + pm.amerc.ai (whiteboard mindmap + netdisk) apps
- agent API keys for fleet read/write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-09 06:03:40 +08:00
commit b055663372
26 changed files with 9411 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules/
dist/
npm-debug.log*
# Heavy non-source artifacts (kept out of the source repo)
ai-deck/
public/models/
assets/
server/data/
*.log

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# amerc
**amerc** — the agent mercenary tavern. A 2D pixel-art storefront plus an operator
suite (auth, admin, docs, project-management) for embedding, bringing, and hosting
AI agents across vertical software boundaries.
Live: <https://amerc.ai>
## The suite (all on the amerc.ai server)
| Site | What |
|------|------|
| `amerc.ai` | 2D Starbound-style pixel tavern (Vite + React, no 3D). Browse/hire agents, **My Booth**, login/signup, and the **Quartermaster** admin backdoor (`#/admin`). |
| `docs.amerc.ai` | Markdown documentation space for humans and agents. |
| `pm.amerc.ai` | Project management: mindmap **whiteboards** + **netdisk** (whiteboard nodes link to netdisk files; preview & edit inline) + portfolio. |
| `git.amerc.ai` | Source hosting (Gitea). |
One amerc account (cookie `Domain=.amerc.ai`) signs you in across all four.
## Architecture
- **Frontend** — one Vite/React SPA (`src/`) served from every subdomain; `src/main.jsx`
picks the app by hostname (`Scene2D` / `DocsApp` / `PmApp`). Pixel sprites in
`public/scene2d/`.
- **Backend**`server/amerc-api.mjs`: a **zero-dependency** Node service
(`node:http` + `node:sqlite` + `node:crypto`). scrypt password hashing, HMAC
HttpOnly session cookies. Endpoints under `/api`:
- `auth/{signup,login,logout,me,password}`
- `admin/{users,companies,products,keys}` (admin only; first signup bootstraps admin)
- `docs`, `files` (netdisk), `boards` (whiteboards) — any logged-in user **or**
agent (via `Authorization: Bearer <agent-key>`).
### Agents
Admins mint **agent keys** in the Quartermaster console. Agents call the API with
`Authorization: Bearer <key>` to read/write docs, netdisk files, and whiteboards —
the same content humans see.
## Develop
```bash
npm install
npm run dev # vite dev server; /api proxies to AMERC_API (default 127.0.0.1:5180)
# backend:
node --experimental-sqlite server/amerc-api.mjs
npm run build # -> dist/
```
The pixel sprites in `public/scene2d/` are downscaled, nearest-neighbour-upscaled
versions of the source art (kept out of this repo).

21
index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries."
/>
<meta name="version" content="0.20.0-suite" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<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=Press+Start+2P&display=swap" rel="stylesheet" />
<title>amerc | 2D pixel mercenary tavern</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1696
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "amerc-site",
"version": "0.20.0-suite",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"lucide-react": "^0.468.0",
"marked": "^14.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"three": "^0.184.0",
"vite": "^7.0.0"
}
}

7
public/favicon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#170704"/>
<path d="M12 48h40l-6-30H18z" fill="#8b4a22"/>
<path d="M20 18h24l4 22H16z" fill="#321207"/>
<path d="M27 10h10l9 9-14 14-14-14z" fill="#42dcff"/>
<path d="M22 47c4-11 16-11 20 0" fill="none" stroke="#f7bc5d" stroke-width="5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

BIN
public/scene2d/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
public/scene2d/dwarf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
public/scene2d/elf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/scene2d/orc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

258
server/amerc-api.mjs Normal file
View File

@ -0,0 +1,258 @@
// amerc-api — zero-dependency backend: auth + admin + docs + netdisk + whiteboards.
// Node 22+ built-ins only: node:http, node:sqlite, node:crypto, node:fs.
// Run: node --experimental-sqlite amerc-api.mjs
// env: PORT, AMERC_DB, AMERC_SECRET, AMERC_COOKIE_DOMAIN, AMERC_FILES
import http from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import crypto from 'node:crypto';
import fs from 'node:fs';
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
import { dirname, join, basename } from 'node:path';
const PORT = Number(process.env.PORT || 5180);
const DB_PATH = process.env.AMERC_DB || './data/amerc.db';
const FILES_DIR = process.env.AMERC_FILES || './data/netdisk';
const SECRET = process.env.AMERC_SECRET || 'dev-insecure-secret-change-me';
const COOKIE_DOMAIN = process.env.AMERC_COOKIE_DOMAIN || ''; // e.g. .amerc.ai in prod
const COOKIE = 'amerc_session';
const DAY = 86400;
const MAX_UPLOAD = 16 * 1024 * 1024;
for (const d of [dirname(DB_PATH), FILES_DIR]) if (!existsSync(d)) mkdirSync(d, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, handle TEXT NOT NULL,
pass TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', status TEXT NOT NULL DEFAULT 'active', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'embedded',
risk TEXT NOT NULL DEFAULT 'green', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company_id INTEGER, status TEXT NOT NULL DEFAULT 'draft',
price TEXT DEFAULT '', notes TEXT DEFAULT '', created_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_hash TEXT NOT NULL, prefix TEXT NOT NULL,
created_at INTEGER NOT NULL, last_used INTEGER);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, folder TEXT DEFAULT '', body TEXT DEFAULT '',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, folder TEXT DEFAULT '', mime TEXT DEFAULT 'application/octet-stream',
size INTEGER DEFAULT 0, store TEXT NOT NULL, kind TEXT DEFAULT 'binary', text_body TEXT DEFAULT '',
created_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]',
updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
`);
// ---- crypto -----------------------------------------------------------------
function hashPassword(pw) {
const salt = crypto.randomBytes(16);
return `scrypt$${salt.toString('hex')}$${crypto.scryptSync(pw, salt, 32).toString('hex')}`;
}
function verifyPassword(pw, stored) {
const [scheme, saltHex, hashHex] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
const want = Buffer.from(hashHex, 'hex');
return hash.length === want.length && crypto.timingSafeEqual(hash, want);
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
function signToken(payload) {
const body = b64url(JSON.stringify({ ...payload, exp: Math.floor(Date.now() / 1000) + 30 * DAY }));
const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
return `${body}.${sig}`;
}
function verifyToken(token) {
if (!token || !token.includes('.')) return null;
const [body, sig] = token.split('.');
const expect = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
try { const d = JSON.parse(Buffer.from(body, 'base64url').toString()); return d.exp < Math.floor(Date.now() / 1000) ? null : d; }
catch { return null; }
}
const hashKey = (k) => crypto.createHash('sha256').update(k).digest('hex');
// ---- http helpers -----------------------------------------------------------
function send(res, code, obj, extra = {}) {
const data = JSON.stringify(obj);
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
res.end(data);
}
function readBody(req, limit = 1e6) {
return new Promise((resolve) => {
let raw = ''; let over = false;
req.on('data', (c) => { raw += c; if (raw.length > limit) { over = true; req.destroy(); } });
req.on('end', () => { if (over) return resolve(null); try { resolve(raw ? JSON.parse(raw) : {}); } catch { resolve({}); } });
req.on('error', () => resolve(null));
});
}
function parseCookies(req) {
const out = {};
(req.headers.cookie || '').split(';').forEach((p) => { const i = p.indexOf('='); if (i > -1) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim()); });
return out;
}
const publicUser = (u) => u && { id: u.id, email: u.email, handle: u.handle, role: u.role, status: u.status };
function sessionCookie(token) {
const dom = COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : '';
return `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${dom}; Max-Age=${30 * DAY}`;
}
const clearCookie = `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax${COOKIE_DOMAIN ? `; Domain=${COOKIE_DOMAIN}` : ''}; Max-Age=0`;
// identity: returns { kind:'user', user } | { kind:'agent', name } | null
function identify(req) {
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) {
const key = auth.slice(7).trim();
const row = db.prepare('SELECT * FROM api_keys WHERE key_hash=?').get(hashKey(key));
if (row) { db.prepare('UPDATE api_keys SET last_used=? WHERE id=?').run(Date.now(), row.id); return { kind: 'agent', name: row.name }; }
}
const data = verifyToken(parseCookies(req)[COOKIE]);
if (data) { const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(data.uid); if (u) return { kind: 'user', user: u }; }
return null;
}
const who = (id) => (id.kind === 'agent' ? `agent:${id.name}` : id.user.handle);
// ---- generic collection CRUD (documents / boards) ---------------------------
function listRows(table, extraOrder = 'updated_at DESC') {
return db.prepare(`SELECT * FROM ${table} ORDER BY ${extraOrder}`).all();
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const path = url.pathname.replace(/^\/api/, '') || '/';
const method = req.method;
let m;
try {
if (path === '/health') return send(res, 200, { ok: true, service: 'amerc-api', version: 2 });
// ---------- AUTH ----------
if (path === '/auth/signup' && method === 'POST') {
const b = await readBody(req); if (!b) return send(res, 400, { ok: false, error: 'bad body' });
const { email, password, handle } = b;
if (!email || !password || password.length < 6) return send(res, 400, { ok: false, error: 'email and password (min 6) required' });
if (db.prepare('SELECT id FROM users WHERE email=?').get(String(email).toLowerCase())) return send(res, 409, { ok: false, error: 'email already registered' });
const role = db.prepare('SELECT COUNT(*) n FROM users').get().n === 0 ? 'admin' : 'member';
const info = db.prepare('INSERT INTO users(email,handle,pass,role,status,created_at) VALUES(?,?,?,?,?,?)')
.run(String(email).toLowerCase(), handle || String(email).split('@')[0], hashPassword(password), role, 'active', Date.now());
const u = db.prepare('SELECT id,email,handle,role,status FROM users WHERE id=?').get(Number(info.lastInsertRowid));
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
}
if (path === '/auth/login' && method === 'POST') {
const b = await readBody(req) || {};
const u = db.prepare('SELECT * FROM users WHERE email=?').get(String(b.email || '').toLowerCase());
if (!u || !verifyPassword(b.password || '', u.pass)) return send(res, 401, { ok: false, error: 'invalid credentials' });
if (u.status !== 'active') return send(res, 403, { ok: false, error: 'account suspended' });
return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) });
}
if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie });
if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); }
if (path === '/auth/password' && method === 'POST') {
const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
const b = await readBody(req) || {};
const u = db.prepare('SELECT * FROM users WHERE id=?').get(id.user.id);
if (!verifyPassword(b.currentPassword || '', u.pass)) return send(res, 401, { ok: false, error: 'current password incorrect' });
if (!b.newPassword || b.newPassword.length < 6) return send(res, 400, { ok: false, error: 'new password min 6 chars' });
db.prepare('UPDATE users SET pass=? WHERE id=?').run(hashPassword(b.newPassword), u.id);
return send(res, 200, { ok: true });
}
// ---------- ADMIN (user role=admin) ----------
if (path.startsWith('/admin/')) {
const id = identify(req);
if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' });
if (id.user.role !== 'admin') return send(res, 403, { ok: false, error: 'admin only' });
const me = id.user;
if (path === '/admin/users' && method === 'GET')
return send(res, 200, { ok: true, users: db.prepare('SELECT id,email,handle,role,status,created_at FROM users ORDER BY id DESC').all() });
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'PATCH') {
const b = await readBody(req) || {}; const u = db.prepare('SELECT * FROM users WHERE id=?').get(Number(m[1]));
if (!u) return send(res, 404, { ok: false, error: 'not found' });
db.prepare('UPDATE users SET role=?,status=?,handle=? WHERE id=?').run(b.role || u.role, b.status || u.status, b.handle || u.handle, u.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(/^\/admin\/users\/(\d+)$/)) && method === 'DELETE') {
if (Number(m[1]) === me.id) return send(res, 400, { ok: false, error: 'cannot delete yourself' });
db.prepare('DELETE FROM users WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true });
}
// api keys (agent tokens)
if (path === '/admin/keys' && method === 'GET')
return send(res, 200, { ok: true, keys: db.prepare('SELECT id,name,prefix,created_at,last_used FROM api_keys ORDER BY id DESC').all() });
if (path === '/admin/keys' && method === 'POST') {
const b = await readBody(req) || {}; const raw = `amk_${crypto.randomBytes(24).toString('base64url')}`;
const info = db.prepare('INSERT INTO api_keys(name,key_hash,prefix,created_at) VALUES(?,?,?,?)').run(b.name || 'agent', hashKey(raw), raw.slice(0, 12), Date.now());
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), key: raw, note: 'store this key now; it is not shown again' });
}
if ((m = path.match(/^\/admin\/keys\/(\d+)$/)) && method === 'DELETE') { db.prepare('DELETE FROM api_keys WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// companies + products generic CRUD
for (const [coll, cols] of [['companies', ['name', 'tier', 'risk', 'notes']], ['products', ['name', 'company_id', 'status', 'price', 'notes']]]) {
if (path === `/admin/${coll}` && method === 'GET') return send(res, 200, { ok: true, [coll]: db.prepare(`SELECT * FROM ${coll} ORDER BY id DESC`).all() });
if (path === `/admin/${coll}` && method === 'POST') {
const b = await readBody(req) || {}; const vals = cols.map((c) => b[c] ?? (c === 'company_id' ? null : ''));
const info = db.prepare(`INSERT INTO ${coll}(${cols.join(',')},created_at) VALUES(${cols.map(() => '?').join(',')},?)`).run(...vals, Date.now());
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
}
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'PATCH') {
const b = await readBody(req) || {}; const row = db.prepare(`SELECT * FROM ${coll} WHERE id=?`).get(Number(m[1]));
if (!row) return send(res, 404, { ok: false, error: 'not found' });
db.prepare(`UPDATE ${coll} SET ${cols.map((c) => `${c}=?`).join(',')} WHERE id=?`).run(...cols.map((c) => b[c] ?? row[c]), row.id);
return send(res, 200, { ok: true });
}
if ((m = path.match(new RegExp(`^/admin/${coll}/(\\d+)$`))) && method === 'DELETE') { db.prepare(`DELETE FROM ${coll} WHERE id=?`).run(Number(m[1])); return send(res, 200, { ok: true }); }
}
return send(res, 404, { ok: false, error: 'not found' });
}
// ---------- CONTENT (docs / files / boards): any authenticated identity ----------
const id = identify(req);
const needAuth = () => send(res, 401, { ok: false, error: 'login or api key required' });
// DOCUMENTS
if (path === '/docs' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, docs: db.prepare('SELECT id,title,folder,updated_by,created_at,updated_at FROM documents ORDER BY folder,title').all() }); }
if (path === '/docs' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req) || {}; const now = Date.now();
const info = db.prepare('INSERT INTO documents(title,folder,body,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.title || 'Untitled', b.folder || '', b.body || '', who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); return d ? send(res, 200, { ok: true, doc: d }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const d = db.prepare('SELECT * FROM documents WHERE id=?').get(Number(m[1])); if (!d) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req) || {};
db.prepare('UPDATE documents SET title=?,folder=?,body=?,updated_by=?,updated_at=? WHERE id=?').run(b.title ?? d.title, b.folder ?? d.folder, b.body ?? d.body, who(id), Date.now(), d.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/docs\/(\d+)$/)) && method === 'DELETE') { if (id?.kind !== 'user') return send(res, 403, { ok: false, error: 'human login required to delete' }); db.prepare('DELETE FROM documents WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
// FILES (netdisk)
if (path === '/files' && method === 'GET') { if (!id) return needAuth(); const folder = url.searchParams.get('folder'); const rows = folder != null ? db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files WHERE folder=? ORDER BY name').all(folder) : db.prepare('SELECT id,name,folder,mime,size,kind,created_by,created_at,updated_at FROM files ORDER BY folder,name').all(); return send(res, 200, { ok: true, files: rows }); }
if (path === '/files' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD); if (!b) return send(res, 413, { ok: false, error: 'file too large (max 16MB)' });
const now = Date.now(); const name = String(b.name || 'file').replace(/[/\\]/g, '_');
if (b.kind === 'text' || (typeof b.text === 'string' && b.data == null)) {
const text = b.text || ''; const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,text_body,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'text/plain', Buffer.byteLength(text), '', 'text', text, who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) });
}
const buf = Buffer.from(String(b.data || ''), 'base64'); const store = `${now}-${crypto.randomBytes(4).toString('hex')}-${name}`;
fs.writeFileSync(join(FILES_DIR, store), buf);
const info = db.prepare('INSERT INTO files(name,folder,mime,size,store,kind,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)').run(name, b.folder || '', b.mime || 'application/octet-stream', buf.length, store, 'binary', who(id), now, now);
return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/files\/(\d+)\/raw$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' });
if (f.kind === 'text') { res.writeHead(200, { 'Content-Type': f.mime || 'text/plain; charset=utf-8' }); return res.end(f.text_body || ''); }
const p = join(FILES_DIR, f.store); if (!existsSync(p)) return send(res, 410, { ok: false, error: 'blob missing' });
res.writeHead(200, { 'Content-Type': f.mime, 'Content-Length': f.size, 'Content-Disposition': `inline; filename="${basename(f.name)}"` }); return createReadStream(p).pipe(res); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); return f ? send(res, 200, { ok: true, file: f }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (!f) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
const text = b.text != null && f.kind === 'text' ? b.text : f.text_body;
db.prepare('UPDATE files SET name=?,folder=?,text_body=?,size=?,updated_at=? WHERE id=?').run(b.name ?? f.name, b.folder ?? f.folder, text, f.kind === 'text' ? Buffer.byteLength(text) : f.size, Date.now(), f.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/files\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); const f = db.prepare('SELECT * FROM files WHERE id=?').get(Number(m[1])); if (f) { if (f.store) try { fs.unlinkSync(join(FILES_DIR, f.store)); } catch {} db.prepare('DELETE FROM files WHERE id=?').run(f.id); } return send(res, 200, { ok: true }); }
// BOARDS (whiteboards)
if (path === '/boards' && method === 'GET') { if (!id) return needAuth(); return send(res, 200, { ok: true, boards: db.prepare('SELECT id,name,links,updated_by,created_at,updated_at FROM boards ORDER BY updated_at DESC').all() }); }
if (path === '/boards' && method === 'POST') { if (!id) return needAuth(); const b = await readBody(req, MAX_UPLOAD) || {}; const now = Date.now();
const info = db.prepare('INSERT INTO boards(name,data,links,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?)').run(b.name || 'Untitled board', b.data || '', JSON.stringify(b.links || []), who(id), now, now); return send(res, 200, { ok: true, id: Number(info.lastInsertRowid) }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'GET') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); return bd ? send(res, 200, { ok: true, board: bd }) : send(res, 404, { ok: false, error: 'not found' }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'PATCH') { if (!id) return needAuth(); const bd = db.prepare('SELECT * FROM boards WHERE id=?').get(Number(m[1])); if (!bd) return send(res, 404, { ok: false, error: 'not found' }); const b = await readBody(req, MAX_UPLOAD) || {};
db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); }
if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); }
return send(res, 404, { ok: false, error: 'not found' });
} catch (err) {
return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) });
}
});
server.listen(PORT, '127.0.0.1', () => console.log(`amerc-api v2 listening on 127.0.0.1:${PORT}, db=${DB_PATH}, files=${FILES_DIR}`));

3
src/App.jsx Normal file
View File

@ -0,0 +1,3 @@
import Scene2DApp from './Scene2D.jsx';
export default Scene2DApp;

200
src/Auth.jsx Normal file
View File

@ -0,0 +1,200 @@
import React, { useCallback, useEffect, useState } from 'react';
import { api } from './api.js';
// Session hook --------------------------------------------------------------
export function useAuth() {
const [user, setUser] = useState(null);
const [ready, setReady] = useState(false);
const refresh = useCallback(async () => {
try { const d = await api('/auth/me'); setUser(d.user || null); }
catch { setUser(null); }
finally { setReady(true); }
}, []);
useEffect(() => { refresh(); }, [refresh]);
const logout = useCallback(async () => { try { await api('/auth/logout', { method: 'POST' }); } catch {} setUser(null); }, []);
return { user, ready, setUser, refresh, logout };
}
// Login / Signup ------------------------------------------------------------
export function AuthPanel({ auth, onDone }) {
const [mode, setMode] = useState('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [handle, setHandle] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
if (auth.user) return <SignedInPanel auth={auth} />;
const submit = async (e) => {
e.preventDefault();
setErr(''); setBusy(true);
try {
const path = mode === 'login' ? '/auth/login' : '/auth/signup';
const d = await api(path, { method: 'POST', body: { email, password, handle } });
auth.setUser(d.user);
onDone && onDone(d.user);
} catch (e2) { setErr(e2.message); }
finally { setBusy(false); }
};
return (
<form className="px-auth" onSubmit={submit}>
<div className="px-auth-tabs">
<button type="button" className={mode === 'login' ? 'active' : ''} onClick={() => setMode('login')}>Login</button>
<button type="button" className={mode === 'signup' ? 'active' : ''} onClick={() => setMode('signup')}>Sign Up</button>
</div>
<label className="px-field"><span>Guild handle email</span>
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="operator@vertical-site.ai" required />
</label>
{mode === 'signup' && (
<label className="px-field"><span>Display name</span>
<input value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="ironworks" />
</label>
)}
<label className="px-field"><span>Access sigil</span>
<input type="password" autoComplete={mode === 'login' ? 'current-password' : 'new-password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="min 6 chars" required minLength={6} />
</label>
{err && <p className="px-auth-err">{err}</p>}
<button type="submit" className="px-action px-auth-submit" disabled={busy}>
{busy ? '…' : mode === 'login' ? 'Enter Booth' : 'Create Account'}
</button>
<p className="px-auth-hint">{mode === 'login' ? 'New mercenary? Switch to Sign Up.' : 'The first account becomes Quartermaster (admin).'}</p>
</form>
);
}
function SignedInPanel({ auth }) {
const [pw, setPw] = useState(false);
const [cur, setCur] = useState('');
const [nw, setNw] = useState('');
const [msg, setMsg] = useState('');
const [err, setErr] = useState('');
const change = async (e) => {
e.preventDefault(); setErr(''); setMsg('');
try { await api('/auth/password', { method: 'POST', body: { currentPassword: cur, newPassword: nw } }); setMsg('Password updated.'); setCur(''); setNw(''); setPw(false); }
catch (e2) { setErr(e2.message); }
};
return (
<div className="px-auth">
<p className="px-auth-hi">Signed in as <strong>{auth.user.handle}</strong> <em>({auth.user.role})</em></p>
{msg && <p className="px-auth-hint" style={{ color: '#36f0b0' }}>{msg}</p>}
<div className="px-auth-actions">
{auth.user.role === 'admin' && <button type="button" className="px-action" onClick={() => { window.location.hash = '#/admin'; }}>Quartermaster</button>}
<a className="px-action" href={`https://pm.amerc.ai/`}>PM Site</a>
<a className="px-action" href={`https://docs.amerc.ai/`}>Docs</a>
<button type="button" className="px-action" onClick={() => setPw((v) => !v)}>Change Password</button>
<button type="button" className="px-action" onClick={() => auth.logout()}>Log Out</button>
</div>
{pw && (
<form className="px-auth" onSubmit={change} style={{ marginTop: 8 }}>
<label className="px-field"><span>Current password</span>
<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} required /></label>
<label className="px-field"><span>New password (min 6)</span>
<input type="password" value={nw} onChange={(e) => setNw(e.target.value)} required minLength={6} /></label>
{err && <p className="px-auth-err">{err}</p>}
<button type="submit" className="px-action px-auth-submit">Update Password</button>
</form>
)}
</div>
);
}
// Admin console (Quartermaster backdoor) ------------------------------------
const TABS = [
{ key: 'users', label: 'Users', cols: ['handle', 'email', 'role', 'status'] },
{ key: 'companies', label: 'Companies', cols: ['name', 'tier', 'risk', 'notes'] },
{ key: 'products', label: 'Products', cols: ['name', 'company_id', 'status', 'price', 'notes'] },
{ key: 'keys', label: 'Agent Keys', cols: ['name', 'prefix', 'last_used'] },
];
export function AdminConsole({ auth }) {
const [tab, setTab] = useState('users');
const [rows, setRows] = useState([]);
const [err, setErr] = useState('');
const [draft, setDraft] = useState({});
const load = useCallback(async (key) => {
setErr('');
try { const d = await api(`/admin/${key}`); setRows(d[key] || []); }
catch (e) { setErr(e.message); setRows([]); }
}, []);
useEffect(() => { if (auth.user?.role === 'admin') load(tab); }, [tab, auth.user, load]);
if (!auth.ready) return <div className="px-admin"><p>Loading</p></div>;
if (!auth.user) {
return (
<div className="px-admin px-admin-gate">
<h3>Backdoor sealed</h3>
<p>Quartermaster credentials required.</p>
<button className="px-action" onClick={() => { window.location.hash = '#/signin'; }}>Go to Login</button>
</div>
);
}
if (auth.user.role !== 'admin') {
return <div className="px-admin px-admin-gate"><h3>Not authorised</h3><p>{auth.user.handle} is a member, not Quartermaster.</p></div>;
}
const meta = TABS.find((t) => t.key === tab);
const editable = meta.cols.filter((c) => tab !== 'users' || true);
const create = async () => {
try {
const r = await api(`/admin/${tab}`, { method: 'POST', body: draft });
if (tab === 'keys' && r.key) window.alert(`Agent key (copy now, shown once):\n\n${r.key}`);
setDraft({}); load(tab);
} catch (e) { setErr(e.message); }
};
const patch = async (id, patchBody) => {
try { await api(`/admin/${tab}/${id}`, { method: 'PATCH', body: patchBody }); load(tab); }
catch (e) { setErr(e.message); }
};
const del = async (id) => {
if (!window.confirm('Delete this record?')) return;
try { await api(`/admin/${tab}/${id}`, { method: 'DELETE' }); load(tab); }
catch (e) { setErr(e.message); }
};
return (
<div className="px-admin">
<div className="px-admin-tabs">
{TABS.map((t) => (
<button key={t.key} className={tab === t.key ? 'active' : ''} onClick={() => setTab(t.key)} type="button">{t.label}</button>
))}
<span className="px-admin-who">{auth.user.handle} · admin</span>
</div>
{err && <p className="px-auth-err">{err}</p>}
<div className="px-admin-table">
<div className="px-admin-row px-admin-head">
{meta.cols.map((c) => <span key={c}>{c}</span>)}
<span>actions</span>
</div>
{rows.map((r) => (
<div className="px-admin-row" key={r.id}>
{meta.cols.map((c) => {
if (tab === 'users' && c === 'role') return (
<select key={c} value={r.role} onChange={(e) => patch(r.id, { role: e.target.value })}>
<option value="member">member</option><option value="admin">admin</option>
</select>);
if (tab === 'users' && c === 'status') return (
<select key={c} value={r.status} onChange={(e) => patch(r.id, { status: e.target.value })}>
<option value="active">active</option><option value="suspended">suspended</option>
</select>);
return <span key={c} title={String(r[c] ?? '')}>{String(r[c] ?? '')}</span>;
})}
<span className="px-admin-acts"><button type="button" onClick={() => del(r.id)}></button></span>
</div>
))}
{!rows.length && <div className="px-admin-row px-admin-empty">no records</div>}
</div>
{tab !== 'users' && (
<div className="px-admin-new">
{(tab === 'keys' ? ['name'] : editable).map((c) => (
<input key={c} placeholder={tab === 'keys' ? 'agent key name' : c} value={draft[c] ?? ''} onChange={(e) => setDraft((d) => ({ ...d, [c]: e.target.value }))} />
))}
<button type="button" className="px-action" onClick={create}>{tab === 'keys' ? '+ Mint Key' : '+ Add'}</button>
</div>
)}
</div>
);
}

64
src/ConsoleShell.jsx Normal file
View File

@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { useAuth } from './Auth.jsx';
const APPS = [
{ label: 'Tavern', href: 'https://amerc.ai/' },
{ label: 'Docs', href: 'https://docs.amerc.ai/' },
{ label: 'PM', href: 'https://pm.amerc.ai/' },
{ label: 'Git', href: 'https://git.amerc.ai/' },
];
function ConsoleLogin({ auth }) {
const [mode, setMode] = useState('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [handle, setHandle] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault(); setErr(''); setBusy(true);
try {
const { api } = await import('./api.js');
const d = await api(mode === 'login' ? '/auth/login' : '/auth/signup', { method: 'POST', body: { email, password, handle } });
auth.setUser(d.user);
} catch (e2) { setErr(e2.message); } finally { setBusy(false); }
};
return (
<div className="cs-login-wrap">
<form className="cs-login" onSubmit={submit}>
<div className="cs-brand"><span className="cs-gem" />amerc</div>
<div className="cs-tabs">
<button type="button" className={mode === 'login' ? 'on' : ''} onClick={() => setMode('login')}>Login</button>
<button type="button" className={mode === 'signup' ? 'on' : ''} onClick={() => setMode('signup')}>Sign Up</button>
</div>
<input type="email" placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
{mode === 'signup' && <input placeholder="display name" value={handle} onChange={(e) => setHandle(e.target.value)} />}
<input type="password" placeholder="password (min 6)" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
{err && <p className="cs-err">{err}</p>}
<button type="submit" className="cs-btn cs-primary" disabled={busy}>{busy ? '…' : mode === 'login' ? 'Enter' : 'Create account'}</button>
<p className="cs-hint">One amerc account across tavern, docs, PM and git.</p>
</form>
</div>
);
}
export default function ConsoleShell({ title, accent = '#27d8ff', current, children }) {
const auth = useAuth();
if (!auth.ready) return <div className="cs-app"><div className="cs-loading">Loading</div></div>;
if (!auth.user) return <div className="cs-app" style={{ '--accent': accent }}><ConsoleLogin auth={auth} /></div>;
return (
<div className="cs-app" style={{ '--accent': accent }}>
<header className="cs-top">
<div className="cs-brand"><span className="cs-gem" />amerc<small>{title}</small></div>
<nav className="cs-appnav">
{APPS.map((a) => <a key={a.label} href={a.href} className={a.label.toLowerCase() === current ? 'on' : ''}>{a.label}</a>)}
</nav>
<div className="cs-user">
<span>{auth.user.handle}{auth.user.role === 'admin' ? ' · admin' : ''}</span>
<button type="button" onClick={() => auth.logout()}>Log out</button>
</div>
</header>
<main className="cs-main">{typeof children === 'function' ? children(auth) : children}</main>
</div>
);
}

86
src/DocsApp.jsx Normal file
View File

@ -0,0 +1,86 @@
import React, { useCallback, useEffect, useState } from 'react';
import { marked } from 'marked';
import ConsoleShell from './ConsoleShell.jsx';
import { api } from './api.js';
marked.setOptions({ breaks: true });
function DocsWorkspace() {
const [docs, setDocs] = useState([]);
const [sel, setSel] = useState(null);
const [doc, setDoc] = useState(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ title: '', folder: '', body: '' });
const [err, setErr] = useState('');
const [q, setQ] = useState('');
const loadList = useCallback(async () => {
try { const d = await api('/docs'); setDocs(d.docs || []); } catch (e) { setErr(e.message); }
}, []);
useEffect(() => { loadList(); }, [loadList]);
useEffect(() => {
if (sel == null) { setDoc(null); return; }
api(`/docs/${sel}`).then((d) => { setDoc(d.doc); setDraft({ title: d.doc.title, folder: d.doc.folder, body: d.doc.body }); setEditing(false); }).catch((e) => setErr(e.message));
}, [sel]);
const create = async () => {
const title = window.prompt('Document title', 'New document'); if (!title) return;
try { const r = await api('/docs', { method: 'POST', body: { title, folder: '', body: `# ${title}\n\n` } }); await loadList(); setSel(r.id); setEditing(true); } catch (e) { setErr(e.message); }
};
const save = async () => { try { await api(`/docs/${sel}`, { method: 'PATCH', body: draft }); await loadList(); setDoc({ ...doc, ...draft }); setEditing(false); } catch (e) { setErr(e.message); } };
const del = async () => { if (!window.confirm('Delete document?')) return; try { await api(`/docs/${sel}`, { method: 'DELETE' }); setSel(null); loadList(); } catch (e) { setErr(e.message); } };
const filtered = docs.filter((d) => (d.title + ' ' + (d.folder || '')).toLowerCase().includes(q.toLowerCase()));
const folders = {};
filtered.forEach((d) => { (folders[d.folder || ''] = folders[d.folder || ''] || []).push(d); });
return (
<div className="docs">
<aside className="docs-side">
<div className="docs-side-top">
<input placeholder="search docs…" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="cs-btn cs-primary" onClick={create}>+ New</button>
</div>
<div className="docs-tree">
{Object.keys(folders).sort().map((f) => (
<div key={f} className="docs-folder">
<div className="docs-folder-name">{f || '📁 root'}</div>
{folders[f].map((d) => (
<button key={d.id} className={`docs-item${sel === d.id ? ' on' : ''}`} onClick={() => setSel(d.id)}>{d.title}</button>
))}
</div>
))}
{!filtered.length && <p className="cs-hint">No documents yet. Create one humans and agents (via API key) can both read & write here.</p>}
</div>
</aside>
<section className="docs-main">
{err && <p className="cs-err">{err}</p>}
{!doc && <div className="docs-empty">Select or create a document.</div>}
{doc && (
<>
<div className="docs-head">
{editing
? <input className="docs-title-input" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} />
: <h2>{doc.title}</h2>}
<div className="docs-actions">
{editing && <input className="docs-folder-input" placeholder="folder" value={draft.folder} onChange={(e) => setDraft({ ...draft, folder: e.target.value })} />}
{!editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
{editing && <button className="cs-btn" onClick={() => { setEditing(false); setDraft({ title: doc.title, folder: doc.folder, body: doc.body }); }}>Cancel</button>}
<button className="cs-btn" onClick={del}>Delete</button>
</div>
</div>
<p className="docs-byline">updated by {doc.updated_by || '—'}</p>
{editing
? <textarea className="docs-editor" value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />
: <article className="docs-render" dangerouslySetInnerHTML={{ __html: marked.parse(doc.body || '') }} />}
</>
)}
</section>
</div>
);
}
export default function DocsApp() {
return <ConsoleShell title="docs" accent="#27d8ff" current="docs">{() => <DocsWorkspace />}</ConsoleShell>;
}

1500
src/GemhallScene.jsx Normal file

File diff suppressed because it is too large Load Diff

2926
src/ModelScene.jsx Normal file

File diff suppressed because it is too large Load Diff

104
src/Netdisk.jsx Normal file
View File

@ -0,0 +1,104 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api, fileUrl, fileToBase64 } from './api.js';
const isImage = (m) => /^image\//.test(m || '');
const isText = (f) => f.kind === 'text' || /^text\/|json|markdown|javascript|xml|csv/.test(f.mime || '');
const human = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`);
// Netdisk file browser. `onPick` (optional) turns rows into a picker for the whiteboard.
export default function Netdisk({ onPick, compact }) {
const [files, setFiles] = useState([]);
const [folder, setFolder] = useState('');
const [sel, setSel] = useState(null);
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const fileInput = useRef(null);
const load = useCallback(async () => {
setErr('');
try { const d = await api('/files'); setFiles(d.files || []); } catch (e) { setErr(e.message); }
}, []);
useEffect(() => { load(); }, [load]);
const folders = Array.from(new Set(['', ...files.map((f) => f.folder || '')])).sort();
const shown = files.filter((f) => (folder ? (f.folder || '') === folder : true));
const upload = async (fileList) => {
setBusy(true); setErr('');
try {
for (const file of fileList) {
const data = await fileToBase64(file);
await api('/files', { method: 'POST', body: { name: file.name, folder, mime: file.type || 'application/octet-stream', data } });
}
await load();
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
const newTextFile = async () => {
const name = window.prompt('New text file name', 'note.md'); if (!name) return;
try { await api('/files', { method: 'POST', body: { name, folder, kind: 'text', text: '', mime: name.endsWith('.md') ? 'text/markdown' : 'text/plain' } }); load(); }
catch (e) { setErr(e.message); }
};
const del = async (id) => { if (!window.confirm('Delete file?')) return; try { await api(`/files/${id}`, { method: 'DELETE' }); setSel(null); load(); } catch (e) { setErr(e.message); } };
return (
<div className={`nd ${compact ? 'nd-compact' : ''}`}>
<div className="nd-bar">
<select value={folder} onChange={(e) => setFolder(e.target.value)}>
{folders.map((f) => <option key={f} value={f}>{f || '— all / root —'}</option>)}
</select>
<input className="nd-folder-input" placeholder="folder for new uploads" value={folder} onChange={(e) => setFolder(e.target.value)} />
<button className="cs-btn" onClick={() => fileInput.current?.click()} disabled={busy}>{busy ? '…' : 'Upload'}</button>
<button className="cs-btn" onClick={newTextFile}>New text</button>
<input ref={fileInput} type="file" multiple hidden onChange={(e) => { upload([...e.target.files]); e.target.value = ''; }} />
</div>
{err && <p className="cs-err">{err}</p>}
<div className="nd-grid">
<div className="nd-list">
{shown.map((f) => (
<div key={f.id} className={`nd-row${sel?.id === f.id ? ' on' : ''}`} onClick={() => setSel(f)}>
<span className="nd-icon">{isImage(f.mime) ? '🖼' : isText(f) ? '📄' : '📦'}</span>
<span className="nd-name" title={f.name}>{f.name}</span>
<span className="nd-meta">{f.folder || '/'} · {human(f.size)}</span>
{onPick && <button className="cs-btn nd-pick" onClick={(e) => { e.stopPropagation(); onPick(f); }}>link</button>}
<button className="nd-del" onClick={(e) => { e.stopPropagation(); del(f.id); }}></button>
</div>
))}
{!shown.length && <div className="nd-empty">no files upload or create one</div>}
</div>
{!compact && <FilePreview file={sel} onChange={load} />}
</div>
</div>
);
}
export function FilePreview({ file, onChange }) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const [err, setErr] = useState('');
useEffect(() => {
setEditing(false); setErr('');
if (file && (file.kind === 'text')) {
api(`/files/${file.id}`).then((d) => setText(d.file.text_body || '')).catch((e) => setErr(e.message));
}
}, [file]);
if (!file) return <div className="nd-preview nd-preview-empty">select a file</div>;
const save = async () => { try { await api(`/files/${file.id}`, { method: 'PATCH', body: { text } }); setEditing(false); onChange && onChange(); } catch (e) { setErr(e.message); } };
return (
<div className="nd-preview">
<div className="nd-preview-head">
<strong>{file.name}</strong>
<span>{file.mime} · {human(file.size)}</span>
<a className="cs-btn" href={fileUrl(file.id)} target="_blank" rel="noreferrer">Open </a>
{file.kind === 'text' && !editing && <button className="cs-btn" onClick={() => setEditing(true)}>Edit</button>}
{editing && <button className="cs-btn cs-primary" onClick={save}>Save</button>}
</div>
{err && <p className="cs-err">{err}</p>}
<div className="nd-preview-body">
{isImage(file.mime) && <img src={fileUrl(file.id)} alt={file.name} className="nd-img" />}
{file.kind === 'text' && editing && <textarea className="nd-text" value={text} onChange={(e) => setText(e.target.value)} />}
{file.kind === 'text' && !editing && <pre className="nd-textview">{text}</pre>}
{!isImage(file.mime) && file.kind !== 'text' && <div className="nd-binary">Binary file <a href={fileUrl(file.id)} target="_blank" rel="noreferrer">download / open</a></div>}
</div>
</div>
);
}

83
src/PmApp.jsx Normal file
View File

@ -0,0 +1,83 @@
import React, { useCallback, useEffect, useState } from 'react';
import ConsoleShell from './ConsoleShell.jsx';
import Netdisk from './Netdisk.jsx';
import Whiteboard from './Whiteboard.jsx';
import { api } from './api.js';
function BoardsTab() {
const [boards, setBoards] = useState([]);
const [open, setOpen] = useState(null);
const [err, setErr] = useState('');
const load = useCallback(async () => { try { const d = await api('/boards'); setBoards(d.boards || []); } catch (e) { setErr(e.message); } }, []);
useEffect(() => { load(); }, [load]);
const create = async () => {
const name = window.prompt('Whiteboard name', 'New mindmap'); if (!name) return;
try { const r = await api('/boards', { method: 'POST', body: { name, data: JSON.stringify({ nodes: [], edges: [] }) } }); await load(); setOpen(r.id); } catch (e) { setErr(e.message); }
};
const del = async (id) => { if (!window.confirm('Delete board?')) return; try { await api(`/boards/${id}`, { method: 'DELETE' }); load(); } catch (e) { setErr(e.message); } };
if (open) return <Whiteboard boardId={open} onClose={() => { setOpen(null); load(); }} />;
return (
<div className="pm-cards">
<div className="pm-cards-head"><h2>Whiteboards</h2><button className="cs-btn cs-primary" onClick={create}>+ New mindmap</button></div>
{err && <p className="cs-err">{err}</p>}
<div className="pm-grid">
{boards.map((b) => (
<div key={b.id} className="pm-card" onClick={() => setOpen(b.id)}>
<div className="pm-card-ico">🧠</div>
<strong>{b.name}</strong>
<span>{(JSON.parse(b.links || '[]') || []).length} linked files</span>
<small>updated {new Date(b.updated_at).toLocaleString()} · {b.updated_by}</small>
<button className="pm-card-del" onClick={(e) => { e.stopPropagation(); del(b.id); }}></button>
</div>
))}
{!boards.length && <p className="cs-hint">No whiteboards yet. Create a mindmap and link netdisk files to its nodes.</p>}
</div>
</div>
);
}
function PortfolioTab() {
const [companies, setCompanies] = useState([]);
const [products, setProducts] = useState([]);
const [err, setErr] = useState('');
useEffect(() => {
Promise.allSettled([api('/admin/companies'), api('/admin/products')]).then(([c, p]) => {
if (c.status === 'fulfilled') setCompanies(c.value.companies || []);
if (p.status === 'fulfilled') setProducts(p.value.products || []);
if (c.status === 'rejected') setErr('Portfolio is admin-only. Ask an admin for access.');
});
}, []);
return (
<div className="pm-portfolio">
<h2>Portfolio</h2>
{err && <p className="cs-hint">{err}</p>}
<div className="pm-port-cols">
<div><h3>Companies</h3>{companies.map((c) => <div key={c.id} className="pm-port-row"><strong>{c.name}</strong><span>{c.tier}</span><em className={`risk-${c.risk}`}>{c.risk}</em></div>)}{!companies.length && <p className="cs-hint"></p>}</div>
<div><h3>Products</h3>{products.map((p) => <div key={p.id} className="pm-port-row"><strong>{p.name}</strong><span>{p.status}</span><em>{p.price}</em></div>)}{!products.length && <p className="cs-hint"></p>}</div>
</div>
</div>
);
}
const TABS = [
{ key: 'boards', label: 'Whiteboards', el: <BoardsTab /> },
{ key: 'disk', label: 'Netdisk', el: <div className="pm-disk"><h2>Netdisk</h2><Netdisk /></div> },
{ key: 'portfolio', label: 'Portfolio', el: <PortfolioTab /> },
];
function PmWorkspace() {
const [tab, setTab] = useState('boards');
return (
<div className="pm">
<nav className="pm-nav">
{TABS.map((t) => <button key={t.key} className={tab === t.key ? 'on' : ''} onClick={() => setTab(t.key)}>{t.label}</button>)}
<a className="pm-nav-ext" href="https://docs.amerc.ai/">Docs </a>
</nav>
<div className="pm-body">{TABS.find((t) => t.key === tab)?.el}</div>
</div>
);
}
export default function PmApp() {
return <ConsoleShell title="pm" accent="#36f0b0" current="pm">{() => <PmWorkspace />}</ConsoleShell>;
}

308
src/Scene2D.jsx Normal file
View File

@ -0,0 +1,308 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Menu, X } from 'lucide-react';
import { useAuth, AuthPanel, AdminConsole } from './Auth.jsx';
const RELEASE = '0.20.0-suite';
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
const NAV = [
{ id: 'home', label: 'Solution' },
{ id: 'agents', label: 'Staff' },
{ id: 'contracts', label: 'Pricing' },
{ id: 'signin', label: 'Login' },
{ id: 'booth', label: 'My Booth' },
];
const VALID_ROUTES = ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'];
function useHashRoute() {
const normalize = () => {
const raw = window.location.hash.replace(/^#\/?/, '');
return VALID_ROUTES.includes(raw) ? raw : 'home';
};
const [route, setRouteState] = useState(normalize);
useEffect(() => {
const onHash = () => setRouteState(normalize());
window.addEventListener('hashchange', onHash);
window.addEventListener('popstate', onHash);
return () => {
window.removeEventListener('hashchange', onHash);
window.removeEventListener('popstate', onHash);
};
}, []);
const setRoute = useCallback((next) => {
const nextHash = `/${next}`;
if (window.location.hash !== `#${nextHash}`) {
window.history.pushState(null, '', `${window.location.pathname}${window.location.search}#${nextHash}`);
}
setRouteState(next);
}, []);
return [route, setRoute];
}
// ---------------------------------------------------------------------------
// Route panel copy (carried over from the 3D scene so nothing regresses)
// ---------------------------------------------------------------------------
function panelData(route) {
const panels = {
home: {
title: 'Browse Agents',
subtitle: 'Walk the tavern floor and inspect the mercenary roster.',
rows: ['Vertical site embeds a command booth', 'Customer backend mints a scoped session', 'Agent acts through supplied skills in tinybox'],
stats: ['12,458 sessions', '7,326 deliveries', '+23.7% usage'],
actions: ['Inspect Roster', 'Publish Agent', 'Remote Use', 'Port Mapping', 'Run Directly'],
tint: '#27d8ff',
},
agents: {
title: 'Mercenary Staff',
subtitle: 'Specialists with scoped tools, runtime limits, and visible feedback.',
rows: ['Iron Tusk / site ops / 18 coin per hour', 'Velvet Hex / user consent / 14 coin per hour', 'Socket Wraith / tinybox bridge / 16 coin per hour'],
stats: ['6 active ranks', 'SLA visible', 'skills scoped'],
actions: ['Filter by Skill', 'Inspect Runtime', 'Hire Specialist', 'Watch Feedback', 'Swap Agent'],
tint: '#27d8ff',
},
booth: {
title: 'My Booth',
subtitle: 'Mount, message, inspect, and carry personal agents into websites.',
rows: ['Iron Tusk / running / cart.update / 00:13', 'Socket Wraith / waiting / claim.lookup / 02:41', 'Nightforge / paused / terminal.chat / 18:09'],
stats: ['3 mounted', '4 callbacks', 'healthy'],
actions: ['Mount Agent', 'Open TUI', 'Send Message', 'Review Logs', 'Detach Booth'],
tint: '#36f0b0',
},
contracts: {
title: 'Contract Pricing',
subtitle: 'Three paths: embedded agent, bring-your-own agent, or hosted agent.',
rows: ['Site hires agent / API key to session', 'User brings agent / connect amerc', 'Hosted mercenary / TUI operations'],
stats: ['session', 'skills', 'runtime'],
actions: ['Create API Key', 'Bind Skills', 'Set Budget', 'Publish Terms', 'Start Session'],
tint: '#f2b85f',
},
admin: {
title: 'Quartermaster',
subtitle: 'Tenant, key, session, skill, and risk controls for operators.',
rows: ['northstar-retail / embedded / green', 'claims-lab / byoa / amber', 'sandbox / hosted / red'],
stats: ['tenants', 'keys', 'risk queue'],
actions: ['Audit Tenants', 'Rotate Keys', 'Trace Sessions', 'Block Skill', 'Review Risk'],
tint: '#f2b85f',
},
signin: {
title: 'Open Booth',
subtitle: 'Sign in to the mercenary control surface.',
rows: ['Guild handle / operator@vertical-site.ai', 'Access sigil / ************', `Release / ${RELEASE}`],
stats: ['preview', 'amerc.ai', 'frontend'],
actions: ['Choose Account', 'Link Agent', 'Grant Scope', 'Enter Booth', 'Return to Site'],
tint: '#9d73ff',
},
};
return panels[route] || panels.home;
}
// Characters that live on the tavern floor. Clicking one routes you.
const CHARACTERS = [
{
id: 'orc', sprite: '/scene2d/orc.png', route: 'agents',
name: 'Grommash', role: 'Bartender', say: 'Pick a mercenary, friend.',
left: '72%', h: 460, z: 6, flip: false, bob: 3.4,
},
{
id: 'elf', sprite: '/scene2d/elf.png', route: 'home',
name: 'Aelis', role: 'Guide', say: 'Welcome to amerc. This way.',
left: '46%', h: 420, z: 5, flip: false, bob: 4.2,
},
{
id: 'dwarf', sprite: '/scene2d/dwarf.png', route: 'booth',
name: 'Durn', role: 'Patron', say: 'My booth runs three agents.',
left: '20%', h: 360, z: 4, flip: true, bob: 3.0,
},
];
// ---------------------------------------------------------------------------
// Top bar
// ---------------------------------------------------------------------------
function PixelTopbar({ route, setRoute, auth }) {
const [open, setOpen] = useState(false);
const go = (id) => { setRoute(id); setOpen(false); };
const loggedIn = !!auth?.user;
const isAdmin = auth?.user?.role === 'admin';
const nav = (
<>
{NAV.filter((item) => !(item.id === 'signin' && loggedIn)).map((item) => (
<button key={item.id} className={`px-nav-btn${route === item.id ? ' active' : ''}`} onClick={() => go(item.id)} type="button">
{item.label}
</button>
))}
{isAdmin && (
<button className={`px-nav-btn quartermaster${route === 'admin' ? ' active' : ''}`} onClick={() => go('admin')} type="button">
Quartermaster
</button>
)}
{loggedIn && (
<button className="px-nav-btn px-nav-user" onClick={() => auth.logout()} type="button" title="Log out">
{auth.user.handle}
</button>
)}
</>
);
return (
<header className="px-topbar">
<button className="px-logo" onClick={() => go('home')} type="button" aria-label="amerc home">
<span className="px-logo-gem" aria-hidden="true" />
<strong>amerc</strong>
<small>agent mercenary tavern</small>
<em>v{RELEASE}</em>
</button>
<nav className="px-nav" aria-label="Primary">{nav}</nav>
<button className="px-menu" onClick={() => setOpen((v) => !v)} type="button" aria-label="Toggle menu">
{open ? <X size={20} /> : <Menu size={20} />}
</button>
{open && <div className="px-mobile">{nav}</div>}
</header>
);
}
// ---------------------------------------------------------------------------
// Scene
// ---------------------------------------------------------------------------
function TavernFloor({ route, setRoute, auth }) {
const stageRef = useRef(null);
const [parallax, setParallax] = useState({ x: 0, y: 0 });
const [active, setActive] = useState(null); // hovered/selected character id
const onMove = useCallback((e) => {
const el = stageRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
setParallax({ x: px, y: py });
}, []);
const embers = useMemo(
() => Array.from({ length: 26 }, (_, i) => ({
id: i,
left: `${(i * 37) % 100}%`,
delay: `${(i % 13) * 0.9}s`,
dur: `${7 + (i % 6)}s`,
size: 2 + (i % 3),
})),
[],
);
return (
<div className="px-stage" ref={stageRef} onMouseMove={onMove} onMouseLeave={() => setParallax({ x: 0, y: 0 })}>
{/* parallax tavern backdrop */}
<div
className="px-bg"
style={{ transform: `translate3d(${parallax.x * -14}px, ${parallax.y * -8}px, 0) scale(1.08)` }}
/>
<div className="px-bg-haze" />
{/* floating embers */}
<div className="px-embers" aria-hidden="true">
{embers.map((e) => (
<span key={e.id} style={{ left: e.left, animationDelay: e.delay, animationDuration: e.dur, width: e.size, height: e.size }} />
))}
</div>
{/* characters on the floor */}
<div
className="px-floor"
style={{ transform: `translate3d(${parallax.x * 10}px, 0, 0)` }}
>
{CHARACTERS.map((c) => (
<button
key={c.id}
type="button"
className={`px-char${active === c.id ? ' active' : ''}${route === c.route ? ' here' : ''}`}
style={{ left: c.left, zIndex: c.z, '--bob': `${c.bob}s` }}
onMouseEnter={() => setActive(c.id)}
onMouseLeave={() => setActive((p) => (p === c.id ? null : p))}
onClick={() => setRoute(c.route)}
aria-label={`${c.name} the ${c.role}`}
>
{(active === c.id || route === c.route) && (
<span className="px-speech">
<strong>{c.name}</strong> · {c.role}
<em>{c.say}</em>
</span>
)}
<img
src={c.sprite}
alt=""
draggable={false}
style={{ height: `min(${c.h}px, 58vh)`, transform: c.flip ? 'scaleX(-1)' : 'none' }}
/>
<span className="px-char-shadow" />
</button>
))}
</div>
{/* warm light + vignette */}
<div className="px-vignette" aria-hidden="true" />
{/* foreground holo board with route content */}
<RoutePanel route={route} setRoute={setRoute} auth={auth} />
</div>
);
}
function RoutePanel({ route, setRoute, auth }) {
const data = panelData(route);
const wide = route === 'admin' && auth.user?.role === 'admin';
return (
<aside className={`px-board${wide ? ' px-board-wide' : ''}`} style={{ '--tint': data.tint }}>
<div className="px-board-rivets" aria-hidden="true" />
<header className="px-board-head">
<span className="px-board-tag">{route === 'admin' ? 'BACKDOOR' : route === 'signin' ? 'GATEHOUSE' : 'WANTED BOARD'}</span>
<h2>{data.title}</h2>
<p>{data.subtitle}</p>
</header>
{route === 'signin' && <AuthPanel auth={auth} onDone={() => setRoute('booth')} />}
{route === 'admin' && <AdminConsole auth={auth} />}
{route !== 'signin' && route !== 'admin' && (
<>
<RouteBody data={data} route={route} setRoute={setRoute} />
</>
)}
</aside>
);
}
function RouteBody({ data, route, setRoute }) {
return (
<>
<ul className="px-board-rows">
{data.rows.map((r, i) => (
<li key={i}><span className="px-bullet" />{r}</li>
))}
</ul>
<div className="px-board-stats">
{data.stats.map((s, i) => (
<span key={i} className="px-stat">{s}</span>
))}
</div>
<div className="px-board-actions">
{data.actions.map((a, i) => (
<button key={i} type="button" className="px-action" onClick={() => setRoute(route === 'home' ? 'agents' : route)}>
{a}
</button>
))}
</div>
</>
);
}
export default function Scene2DApp() {
const [route, setRoute] = useHashRoute();
const auth = useAuth();
return (
<main className="px-app">
<h1 className="sr-only">amerc agent mercenary tavern</h1>
<PixelTopbar route={route} setRoute={setRoute} auth={auth} />
<TavernFloor route={route} setRoute={setRoute} auth={auth} />
</main>
);
}

958
src/SplashScene.jsx Normal file
View File

@ -0,0 +1,958 @@
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { Menu, X } from 'lucide-react';
import tavernBackdropUrl from '../assets/scene2d/tavern-bg-039.png';
import orcBartenderUrl from '../assets/scene2d/orc-bartender-trim-081.png';
import elfGuideUrl from '../assets/scene2d/elf-guide-trim-081.png';
import dwarfPatronUrl from '../assets/scene2d/dwarf-patron-trim-081.png';
const RELEASE = '0.8.4-splash';
const BACKDROP_ASPECT = 1672 / 941;
const ORC_ASPECT = 1024 / 1387;
const ELF_ASPECT = 777 / 1276;
const DWARF_ASPECT = 939 / 1332;
const SCREEN_ASPECT = 1220 / 520;
const BOOTH_ASPECT = 980 / 250;
const SPEECH_ASPECT = 820 / 520;
const NAV_ROUTES = [
{ id: 'home', label: 'Solution' },
{ id: 'agents', label: 'Staff' },
{ id: 'contracts', label: 'Pricing' },
{ id: 'signin', label: 'Sign Up' },
{ id: 'signin', label: 'Login' },
{ id: 'booth', label: 'My Booth' },
];
const WANTED_AGENTS = [
{ name: 'Velvet Hex', role: 'consent guide', skill: 'BYOA links', bounty: '14c/h', initials: 'VH', tint: '#b989ff' },
{ name: 'Socket Wraith', role: 'tinybox bridge', skill: 'port mapping', bounty: '16c/h', initials: 'SW', tint: '#54d9ff' },
{ name: 'Iron Tusk', role: 'ops bartender', skill: 'publish agent', bounty: '18c/h', initials: 'IT', tint: '#7be36d' },
{ name: 'Amber Ledger', role: 'contract clerk', skill: 'pricing audit', bounty: '12c/h', initials: 'AL', tint: '#ffc45f' },
{ name: 'Northstar Fixer', role: 'merchant ops', skill: 'session scope', bounty: '20c/h', initials: 'NF', tint: '#a7ecff' },
{ name: 'Cinder Relay', role: 'delivery relay', skill: 'remote use', bounty: '15c/h', initials: 'CR', tint: '#ff8564' },
];
const SPEECH_STEPS = {
home: ['Publish Agent', 'Remote Use', 'Port Mapping', 'Delivery Relay', 'Run Directly'],
agents: ['Inspect Roster', 'Check Skills', 'Open Contract', 'Hire Session', 'Audit Scope'],
booth: ['Attach Skills', 'Set Runtime', 'Approve Budget', 'Launch Booth', 'Watch Output'],
contracts: ['Scope Bounty', 'Limit Spend', 'Rotate Keys', 'Review Logs', 'Renew Term'],
signin: ['Create Pass', 'Link Agent', 'Bring Token', 'Confirm Consent', 'Enter Booth'],
admin: ['Tenant Keys', 'Session Health', 'Runtime Gate', 'Audit Queue', 'Deploy Static'],
};
const ASSET_AUDIT = [
{ area: 'tavern backdrop', asset: 'assets/scene2d/tavern-bg-039.png', source: 'imagegen reference-guided game splash art', rating: '8.8/10' },
{ area: 'orc bartender', asset: 'assets/scene2d/orc-bartender-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.6/10' },
{ area: 'elf guide', asset: 'assets/scene2d/elf-guide-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.7/10' },
{ area: 'dwarf patron', asset: 'assets/scene2d/dwarf-patron-trim-081.png', source: 'imagegen character sprite with chroma-key alpha, alpha-trimmed for framing', rating: '8.5/10' },
{ area: 'browse agents screen', asset: 'animated CanvasTexture wanted board', source: 'repo-generated canvas UI', rating: '8.3/10' },
{ area: 'enter booth sign', asset: 'animated CanvasTexture neon sign', source: 'repo-generated canvas UI', rating: '8.2/10' },
{ area: 'topbar', asset: 'CSS carved wood/gem nav', source: 'repo-generated CSS material', rating: '8.0/10' },
];
const IDLE_ANIMATIONS = [
'orc sprite: breathing scale, shoulder sway, mug glow pulse, cyan armor flicker',
'elf sprite: cape sway, hand presentation bob, gem pulse, speech bubble pulse',
'dwarf sprite: seated toast bob, mug lift beat, backpack core glow',
'browse agents screen: wanted roster scroll, badge cycling, coin burst motion',
'enter booth sign: neon tube flicker, scanline sweep, floor ring pulse',
'environment: lantern glow pulses, crystal glints, ember drift, drag parallax',
];
export default function SplashSceneApp() {
const [route, setRoute] = useHashRoute();
return (
<main className="amerc-app gemhall-app">
<h1 className="sr-only">amerc agent mercenary tavern</h1>
<WoodTopbar route={route} setRoute={setRoute} />
<SplashScene route={route} setRoute={setRoute} />
</main>
);
}
function useHashRoute() {
const normalize = () => {
const raw = window.location.hash.replace(/^#\/?/, '');
return ['home', 'agents', 'booth', 'contracts', 'admin', 'signin'].includes(raw) ? raw : 'home';
};
const [route, setRouteState] = useState(normalize);
useEffect(() => {
const onHash = () => setRouteState(normalize());
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
const setRoute = (nextRoute) => {
window.location.hash = `/${nextRoute}`;
setRouteState(nextRoute);
};
return [route, setRoute];
}
function WoodTopbar({ route, setRoute }) {
const [open, setOpen] = useState(false);
const nav = (
<>
{NAV_ROUTES.map((item, index) => (
<button
className={route === item.id ? 'active' : ''}
key={`${item.id}-${item.label}-${index}`}
onClick={() => {
setRoute(item.id);
setOpen(false);
}}
type="button"
>
{item.label}
</button>
))}
<button
className={route === 'admin' ? 'active quartermaster-link' : 'quartermaster-link'}
onClick={() => {
setRoute('admin');
setOpen(false);
}}
type="button"
>
Quartermaster
</button>
</>
);
return (
<header className="wood-topbar">
<button className="logo-plaque" onClick={() => setRoute('home')} type="button" aria-label="amerc home">
<span className="logo-gem" aria-hidden="true" />
<strong>amerc</strong>
<small>agent mercenary</small>
<em>v{RELEASE}</em>
</button>
<nav className="wood-nav" aria-label="Primary navigation">
{nav}
</nav>
<button className="wood-menu" onClick={() => setOpen((value) => !value)} type="button" aria-label="Toggle menu">
{open ? <X size={21} /> : <Menu size={21} />}
</button>
{open && <div className="wood-mobile">{nav}</div>}
</header>
);
}
function SplashScene({ route, setRoute }) {
const mountRef = useRef(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return undefined;
let renderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
preserveDrawingBuffer: true,
powerPreference: 'high-performance',
});
} catch {
setFallback(true);
return undefined;
}
setFallback(false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.domElement.setAttribute('data-version', RELEASE);
renderer.domElement.style.cursor = 'grab';
renderer.domElement.style.touchAction = 'none';
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x020101);
const camera = new THREE.PerspectiveCamera(35, 16 / 9, 0.1, 80);
const target = new THREE.Vector3(0, -0.15, -2);
const stage = new THREE.Group();
scene.add(stage);
const built = buildSplashScene(route);
stage.add(built.root);
const interactives = [built.dashboard, built.boothSign, built.speech];
const tracked = {
dashboard: built.dashboard,
booth: built.boothSign,
speech: built.speech,
orc: built.orc,
elf: built.elf,
dwarf: built.dwarf,
};
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const drag = { active: false, moved: false, startX: 0, startY: 0, pointerId: null };
let hoverRoute = '';
let frame = 0;
let disposed = false;
let layout = 'desktop';
let currentYaw = 0;
let currentPitch = 0;
let targetYaw = 0;
let targetPitch = 0;
const metrics = () => ({
assetMode: 'imagegen bitmap asset pack plus Three.js layered parallax and animated canvas UI',
assetAudit: ASSET_AUDIT,
idleAnimations: IDLE_ANIMATIONS,
generatedAssets: [tavernBackdropUrl, orcBartenderUrl, elfGuideUrl, dwarfPatronUrl],
dynamicFrames: {
wantedList: built.dashboard.material.map?.userData?.frame || 0,
speech: built.speech.material.map?.userData?.frame || 0,
boothNeon: built.boothSign.material.map?.userData?.frame || 0,
},
rosterScroll: round(built.dashboard.material.map?.userData?.scroll || 0),
rosterActiveAgent: built.dashboard.material.map?.userData?.activeAgent || WANTED_AGENTS[0].name,
visualTarget: 'reference-matched polished fantasy sci-fi tavern splash art',
spriteAssets: true,
lowPoly: true,
draggable: true,
});
const setLayout = () => {
const rect = mount.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || window.innerWidth || 1));
const height = Math.max(1, Math.floor(rect.height || window.innerHeight || 1));
const aspect = width / height;
const portrait = height > width * 1.05;
layout = width < 760 && portrait ? 'mobile' : width < 1080 || aspect < 1.22 ? 'tablet' : 'desktop';
renderer.setSize(width, height, false);
camera.aspect = aspect;
if (layout === 'mobile') {
camera.fov = 44;
camera.position.set(0, 1.58, 7.45);
target.set(0, -0.18, -1.95);
stage.scale.setScalar(1.08);
stage.position.set(0, 0.24, 0);
placeMobile(built);
} else if (layout === 'tablet') {
camera.fov = 42;
camera.position.set(0, 1.72, 8.55);
target.set(0, -0.22, -2);
stage.scale.setScalar(1);
stage.position.set(0, -0.03, 0);
placeTablet(built);
} else {
camera.fov = 38;
camera.position.set(0, 1.56, 8.65);
target.set(0, -0.18, -2.05);
stage.scale.setScalar(1);
stage.position.set(0, 0, 0);
placeDesktop(built);
}
camera.updateProjectionMatrix();
camera.lookAt(target);
publishStageDebug({ width, height, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount: countMeshes(stage), ...metrics() }, tracked, camera);
};
const resizeObserver = new ResizeObserver(setLayout);
resizeObserver.observe(mount);
setLayout();
const pickRoute = (event) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / Math.max(rect.height, 1)) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(interactives, false)[0];
return hit?.object?.userData?.route || '';
};
const onPointerDown = (event) => {
drag.active = true;
drag.moved = false;
drag.startX = event.clientX;
drag.startY = event.clientY;
drag.pointerId = event.pointerId;
renderer.domElement.setPointerCapture?.(event.pointerId);
renderer.domElement.style.cursor = 'grabbing';
};
const onPointerMove = (event) => {
if (drag.active) {
const dx = event.clientX - drag.startX;
const dy = event.clientY - drag.startY;
if (Math.hypot(dx, dy) > 4) drag.moved = true;
targetYaw = clamp(dx * 0.0012, -0.16, 0.16);
targetPitch = clamp(dy * 0.00075, -0.06, 0.06);
return;
}
hoverRoute = pickRoute(event);
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
};
const onPointerUp = (event) => {
renderer.domElement.releasePointerCapture?.(event.pointerId);
drag.active = false;
drag.pointerId = null;
if (!drag.moved) {
const nextRoute = pickRoute(event);
if (nextRoute) setRoute(nextRoute);
}
hoverRoute = pickRoute(event);
renderer.domElement.style.cursor = hoverRoute ? 'pointer' : 'grab';
};
const onPointerLeave = () => {
if (!drag.active) {
hoverRoute = '';
renderer.domElement.style.cursor = 'grab';
}
};
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointermove', onPointerMove);
renderer.domElement.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('pointercancel', onPointerUp);
renderer.domElement.addEventListener('pointerleave', onPointerLeave);
const start = performance.now();
const animate = () => {
if (disposed) return;
frame = requestAnimationFrame(animate);
const t = (performance.now() - start) / 1000;
currentYaw += (targetYaw - currentYaw) * 0.12;
currentPitch += (targetPitch - currentPitch) * 0.12;
stage.rotation.y = currentYaw + Math.sin(t * 0.28) * 0.004;
stage.rotation.x = currentPitch;
animateSprites(built, t);
built.updaters.forEach((update) => update(t));
interactives.forEach((object) => {
const base = object.userData.baseScale || object.scale;
const targetScale = object.userData.route === hoverRoute ? 1.035 : 1;
object.scale.lerp(new THREE.Vector3(base.x * targetScale, base.y * targetScale, base.z), 0.16);
});
if (Math.floor(t * 10) % 3 === 0) {
publishStageDebug(
{ width: renderer.domElement.clientWidth, height: renderer.domElement.clientHeight, layout, dragYaw: currentYaw, dragPitch: currentPitch, meshCount: countMeshes(stage), ...metrics() },
tracked,
camera,
);
}
renderer.render(scene, camera);
};
animate();
return () => {
disposed = true;
resizeObserver.disconnect();
renderer.domElement.removeEventListener('pointerdown', onPointerDown);
renderer.domElement.removeEventListener('pointermove', onPointerMove);
renderer.domElement.removeEventListener('pointerup', onPointerUp);
renderer.domElement.removeEventListener('pointercancel', onPointerUp);
renderer.domElement.removeEventListener('pointerleave', onPointerLeave);
cancelAnimationFrame(frame);
disposeScene(scene);
renderer.dispose();
if (renderer.domElement.parentNode === mount) mount.removeChild(renderer.domElement);
};
}, [route, setRoute]);
return (
<div className="tavern-stage gemhall-stage" ref={mountRef} aria-label="Draggable polished bitmap-asset AI mercenary tavern">
{fallback && <div className="webgl-fallback" aria-hidden="true" />}
</div>
);
}
function buildSplashScene(route) {
const root = new THREE.Group();
const bg = makeImagePlane(tavernBackdropUrl, BACKDROP_ASPECT, { renderOrder: -20, transparent: false });
bg.userData.asset = 'imagegen tavern background';
root.add(bg);
const title = makeCanvasPlane(makeTitleTexture(route).texture, { renderOrder: 24, depthTest: false });
const wantedTexture = makeWantedTexture(route);
const speechTexture = makeSpeechTexture(route);
const boothTexture = makeBoothTexture();
const dashboard = makeCanvasPlane(wantedTexture.texture, { renderOrder: 28, depthTest: true, route: 'agents' });
const boothSign = makeCanvasPlane(boothTexture.texture, { renderOrder: 34, depthTest: true, route: 'booth' });
const speech = makeCanvasPlane(speechTexture.texture, { renderOrder: 36, depthTest: true, route: route === 'signin' ? 'booth' : 'agents' });
const dwarf = makeSpriteGroup(dwarfPatronUrl, DWARF_ASPECT, 'dwarf patron sprite');
const orc = makeSpriteGroup(orcBartenderUrl, ORC_ASPECT, 'orc bartender sprite');
const elf = makeSpriteGroup(elfGuideUrl, ELF_ASPECT, 'elf guide sprite');
const particles = makeParticles();
const boothRing = makeRingSet();
root.add(dashboard, boothSign, speech, dwarf, orc, elf, particles, boothRing);
return {
root,
bg,
title,
dashboard,
boothSign,
speech,
dwarf,
orc,
elf,
particles,
boothRing,
updaters: [wantedTexture.update, speechTexture.update, boothTexture.update],
};
}
function makeImagePlane(url, aspect, options = {}) {
const texture = new THREE.TextureLoader().load(url);
prepareTexture(texture);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: options.transparent ?? true,
depthTest: options.depthTest ?? true,
depthWrite: options.depthWrite ?? false,
alphaTest: options.transparent === false ? 0 : 0.02,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(aspect, 1), material);
mesh.renderOrder = options.renderOrder ?? 0;
return mesh;
}
function makeSpriteGroup(url, aspect, label) {
const group = new THREE.Group();
const sprite = makeImagePlane(url, aspect, { renderOrder: 20, transparent: true, depthWrite: false });
sprite.userData.baseScale = sprite.scale.clone();
group.add(sprite);
group.userData.sprite = sprite;
group.userData.asset = label;
group.userData.baseY = 0;
group.userData.baseRotY = 0;
group.userData.baseScale = 1;
return group;
}
function makeCanvasPlane(texture, { renderOrder = 10, route = '', depthTest = true } = {}) {
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthTest,
depthWrite: false,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
mesh.renderOrder = renderOrder;
mesh.userData.route = route;
mesh.userData.baseScale = mesh.scale.clone();
return mesh;
}
function makeTitleTexture(route) {
return makeAnimatedTexture(1240, 260, (ctx, width, height, time) => {
const routeLabel = route === 'home' ? 'Hire agents like mercenaries.' : routeTitle(route);
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.68)';
ctx.shadowBlur = 18;
ctx.fillStyle = 'rgba(22, 8, 4, .78)';
ctx.strokeStyle = 'rgba(255, 196, 93, .72)';
ctx.lineWidth = 5;
drawRound(ctx, 190, 36, width - 380, height - 70, 34);
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = 'rgba(0,0,0,.7)';
ctx.shadowBlur = 14;
ctx.fillStyle = '#fff0d7';
ctx.font = '900 48px Georgia, Times New Roman, serif';
fitText(ctx, routeLabel, width / 2, 96, width - 500, 48);
ctx.font = '900 21px Inter, Arial, sans-serif';
ctx.fillStyle = '#ffc96e';
fitText(ctx, 'live contracts / agent roster / private booth', width / 2, 150, width - 560, 21);
ctx.fillStyle = `rgba(80, 225, 255, ${0.5 + Math.sin(time * 2.2) * 0.16})`;
ctx.fillRect(390, 188, 460 + Math.sin(time * 0.9) * 60, 4);
return {};
});
}
function makeWantedTexture(route) {
return makeAnimatedTexture(1220, 520, (ctx, width, height, time) => {
const scroll = positiveModulo(time * 56, 118);
const active = WANTED_AGENTS[Math.floor(time / 2.25) % WANTED_AGENTS.length];
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.shadowColor = '#1fe7ff';
ctx.shadowBlur = 24;
ctx.fillStyle = 'rgba(5, 21, 36, .9)';
ctx.strokeStyle = 'rgba(54, 226, 255, .92)';
ctx.lineWidth = 8;
drawRound(ctx, 22, 20, width - 44, height - 40, 34);
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.strokeStyle = 'rgba(65, 230, 255, .12)';
ctx.lineWidth = 1;
for (let x = 60; x < width - 60; x += 48) {
ctx.beginPath();
ctx.moveTo(x, 36);
ctx.lineTo(x, height - 42);
ctx.stroke();
}
for (let y = 72; y < height - 48; y += 42) {
ctx.beginPath();
ctx.moveTo(44, y);
ctx.lineTo(width - 44, y);
ctx.stroke();
}
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.shadowColor = '#33eaff';
ctx.shadowBlur = 18;
ctx.fillStyle = '#bff8ff';
ctx.font = '900 56px Georgia, Times New Roman, serif';
fitText(ctx, 'BROWSE AGENTS', 64, 62, 520, 56, 'left');
ctx.shadowBlur = 0;
ctx.fillStyle = '#ffd36a';
ctx.font = '900 23px Inter, Arial, sans-serif';
fitText(ctx, 'WANTED LIST / SCOPED MERCENARIES', 68, 112, 570, 23, 'left');
ctx.save();
ctx.beginPath();
drawRound(ctx, 52, 132, 674, 292, 20);
ctx.clip();
for (let i = -1; i < WANTED_AGENTS.length + 3; i += 1) {
const agent = WANTED_AGENTS[positiveModulo(i, WANTED_AGENTS.length)];
drawWantedRow(ctx, agent, 72, 144 + i * 86 - scroll, time + i);
}
ctx.restore();
drawActiveAgent(ctx, active, 770, 126, 360, 285, time);
for (let i = 0; i < 24; i += 1) {
const x = 600 + Math.cos(time * 1.8 + i * 0.7) * (60 + (i % 5) * 16);
const y = 258 + Math.sin(time * 2.1 + i * 1.3) * (38 + (i % 4) * 9);
ctx.globalAlpha = 0.45 + (i % 3) * 0.14;
ctx.fillStyle = '#ffc45f';
ctx.beginPath();
ctx.ellipse(x, y, 9, 14, time + i, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
return { scroll, activeAgent: active.name };
});
}
function drawWantedRow(ctx, agent, x, y, time) {
ctx.save();
ctx.shadowColor = agent.tint;
ctx.shadowBlur = 10;
ctx.fillStyle = 'rgba(7, 31, 45, .82)';
ctx.strokeStyle = agent.tint;
ctx.lineWidth = 2;
drawRound(ctx, x, y, 620, 58, 16);
ctx.fill();
ctx.stroke();
ctx.fillStyle = 'rgba(0,0,0,.38)';
ctx.beginPath();
ctx.arc(x + 34, y + 29, 20, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = agent.tint;
ctx.font = '900 15px Inter, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(agent.initials, x + 34, y + 29);
ctx.textAlign = 'left';
ctx.shadowBlur = 0;
ctx.fillStyle = '#f2fbff';
ctx.font = '900 20px Georgia, Times New Roman, serif';
fitText(ctx, agent.name, x + 74, y + 21, 250, 20, 'left');
ctx.fillStyle = '#b8d7df';
ctx.font = '800 14px Inter, Arial, sans-serif';
fitText(ctx, agent.role, x + 74, y + 42, 250, 14, 'left');
ctx.fillStyle = '#c4f3ff';
fitText(ctx, agent.skill, x + 395, y + 27, 130, 14, 'left');
ctx.fillStyle = '#ffd36a';
ctx.font = '900 18px Inter, Arial, sans-serif';
fitText(ctx, agent.bounty, x + 545, y + 28 + Math.sin(time * 2) * 1.5, 70, 18, 'left');
ctx.restore();
}
function drawActiveAgent(ctx, agent, x, y, width, height, time) {
ctx.save();
ctx.shadowColor = agent.tint;
ctx.shadowBlur = 24;
ctx.fillStyle = 'rgba(4, 20, 32, .86)';
ctx.strokeStyle = agent.tint;
ctx.lineWidth = 5;
drawRound(ctx, x, y, width, height, 26);
ctx.fill();
ctx.stroke();
ctx.translate(x + width / 2, y + 86);
ctx.rotate(Math.sin(time * 0.9) * 0.08);
ctx.strokeStyle = agent.tint;
ctx.lineWidth = 7;
drawHex(ctx, 0, 0, 56 + Math.sin(time * 3) * 5);
ctx.stroke();
ctx.fillStyle = agent.tint;
ctx.font = '900 35px Inter, Arial, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(agent.initials, 0, 7);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.shadowBlur = 0;
ctx.textAlign = 'center';
ctx.fillStyle = '#f3fbff';
ctx.font = '900 27px Georgia, Times New Roman, serif';
fitText(ctx, agent.name, x + width / 2, y + 176, width - 60, 27);
ctx.fillStyle = '#c8f7ff';
ctx.font = '800 18px Inter, Arial, sans-serif';
fitText(ctx, agent.role, x + width / 2, y + 210, width - 70, 18);
ctx.fillStyle = '#ffd36a';
ctx.font = '900 22px Inter, Arial, sans-serif';
fitText(ctx, `Bounty ${agent.bounty}`, x + width / 2, y + 250, width - 80, 22);
ctx.restore();
}
function makeSpeechTexture(route) {
const lines = SPEECH_STEPS[route] || SPEECH_STEPS.home;
return makeAnimatedTexture(820, 520, (ctx, width, height, time) => {
const active = Math.floor(time / 1.45) % lines.length;
const pulse = 0.74 + Math.sin(time * 3.8) * 0.14;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.shadowColor = '#35dfff';
ctx.shadowBlur = 24 * pulse;
ctx.fillStyle = 'rgba(3, 22, 39, .88)';
ctx.strokeStyle = `rgba(58, 222, 255, ${0.72 + pulse * 0.2})`;
ctx.lineWidth = 7;
drawRound(ctx, 28, 26, width - 80, height - 92, 28);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(width - 150, height - 72);
ctx.lineTo(width - 44, height - 24);
ctx.lineTo(width - 104, height - 118);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#bff8ff';
ctx.font = '900 34px Georgia, Times New Roman, serif';
fitText(ctx, 'Velvet Hex offers', 70, 76, width - 160, 34, 'left');
ctx.font = '900 27px Inter, Arial, sans-serif';
lines.forEach((line, index) => {
const y = 145 + index * 58;
const selected = index === active;
ctx.globalAlpha = selected ? 1 : 0.44;
ctx.strokeStyle = selected ? '#5ee8ff' : 'rgba(94,232,255,.6)';
ctx.lineWidth = selected ? 5 : 3;
ctx.beginPath();
ctx.arc(78, y, selected ? 14 + Math.sin(time * 6) * 2 : 9, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = selected ? '#5ee8ff' : '#c9f5ff';
fitText(ctx, line, 112, y, width - 190, 27, 'left');
});
ctx.globalAlpha = 1;
return {};
});
}
function makeBoothTexture() {
return makeAnimatedTexture(980, 250, (ctx, width, height, time) => {
const flicker = 0.72 + Math.sin(time * 9.5) * 0.08 + (Math.sin(time * 27) > 0.94 ? 0.18 : 0);
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.shadowColor = '#24e6ff';
ctx.shadowBlur = 28 * flicker;
ctx.fillStyle = 'rgba(1, 19, 32, .82)';
ctx.strokeStyle = `rgba(44, 230, 255, ${0.75 * flicker})`;
ctx.lineWidth = 8;
drawRound(ctx, 38, 38, width - 76, height - 76, 28);
ctx.fill();
ctx.stroke();
ctx.font = '900 66px Georgia, Times New Roman, serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.strokeStyle = `rgba(38, 225, 255, ${0.95 * flicker})`;
ctx.lineWidth = 7;
ctx.strokeText('Enter My Booth', width / 2, height / 2 - 6, width - 130);
ctx.fillStyle = `rgba(232, 255, 255, ${0.96 * flicker})`;
ctx.fillText('Enter My Booth', width / 2, height / 2 - 6, width - 130);
ctx.fillStyle = `rgba(255, 210, 107, ${0.84 * flicker})`;
ctx.font = '900 18px Inter, Arial, sans-serif';
fitText(ctx, 'private agent command booth', width / 2, height - 60, width - 180, 18);
ctx.fillStyle = `rgba(49, 229, 255, ${0.26 * flicker})`;
ctx.fillRect(74 + positiveModulo(time * 180, width - 230), 54, 120, 5);
ctx.restore();
return {};
});
}
function makeParticles() {
const group = new THREE.Group();
const material = new THREE.MeshBasicMaterial({ color: 0xffc36e, transparent: true, opacity: 0.85 });
for (let i = 0; i < 48; i += 1) {
const dot = new THREE.Mesh(new THREE.CircleGeometry(0.012 + (i % 3) * 0.004, 6), material.clone());
dot.position.set(-5.8 + ((i * 1.83) % 11.6), -1.35 + ((i * 0.43) % 4.1), -0.4 - (i % 5) * 0.34);
dot.userData.seed = i * 0.47;
dot.renderOrder = 38;
group.add(dot);
}
return group;
}
function makeRingSet() {
const group = new THREE.Group();
const material = new THREE.MeshBasicMaterial({ color: 0x1ee6ff, transparent: true, opacity: 0.62, side: THREE.DoubleSide, depthWrite: false });
[0.56, 0.78, 1.0].forEach((radius) => {
const ring = new THREE.Mesh(new THREE.RingGeometry(radius, radius + 0.012, 96), material.clone());
ring.rotation.x = -Math.PI / 2;
ring.renderOrder = 31;
group.add(ring);
});
return group;
}
function animateSprites(built, time) {
const orc = built.orc;
orc.position.y = orc.userData.baseY + Math.sin(time * 1.15) * 0.035;
orc.rotation.z = Math.sin(time * 0.8) * 0.006;
orc.scale.setScalar(orc.userData.baseScale * (1 + Math.sin(time * 1.25) * 0.012));
const elf = built.elf;
elf.position.y = elf.userData.baseY + Math.sin(time * 1.45 + 0.9) * 0.045;
elf.rotation.z = Math.sin(time * 1.1) * 0.01;
elf.scale.setScalar(elf.userData.baseScale * (1 + Math.sin(time * 1.8) * 0.008));
const dwarf = built.dwarf;
dwarf.position.y = dwarf.userData.baseY + Math.sin(time * 1.8 + 1.9) * 0.028;
dwarf.rotation.z = Math.sin(time * 1.6) * 0.012;
dwarf.scale.setScalar(dwarf.userData.baseScale * (1 + Math.sin(time * 2.0 + 0.5) * 0.01));
built.boothRing.rotation.z = time * 0.12;
built.boothRing.children.forEach((ring, index) => {
ring.material.opacity = 0.32 + Math.sin(time * 2.4 + index * 0.8) * 0.14;
});
built.particles.children.forEach((dot) => {
const seed = dot.userData.seed || 0;
dot.position.y += Math.sin(time * 1.3 + seed) * 0.00055;
dot.material.opacity = 0.42 + Math.sin(time * 2.2 + seed) * 0.28;
});
}
function placeDesktop(built) {
placePlane(built.bg, 14.4, 14.4 / BACKDROP_ASPECT, 0, -0.18, -4.3, 0);
placePlane(built.title, 4.45, 0.72, -0.32, 1.22, -1.62, 0);
placePlane(built.dashboard, 4.55, 4.55 / SCREEN_ASPECT, -0.36, 0.43, -1.54, -0.02);
placePlane(built.boothSign, 2.8, 2.8 / BOOTH_ASPECT, -0.05, -1.68, 0.1, 0);
placePlane(built.speech, 1.88, 1.88 / SPEECH_ASPECT, 2.92, 0.04, 0.32, -0.1);
placeSprite(built.orc, 2.34, 2.34 / ORC_ASPECT, 0.02, -1.38, 0.26, 0.02, 1);
placeSprite(built.elf, 1.46, 1.46 / ELF_ASPECT, 3.04, -1.42, 0.5, -0.12, 1);
placeSprite(built.dwarf, 1.18, 1.18 / DWARF_ASPECT, -3.04, -1.46, 0.58, 0.14, 1);
placeFlat(built.boothRing, -0.1, -2, 0.9, 1.05, 0);
}
function placeTablet(built) {
placePlane(built.bg, 13.0, 13.0 / BACKDROP_ASPECT, 0, -0.18, -4.1, 0);
placePlane(built.title, 3.7, 0.62, 0, 1.08, -1.35, 0);
placePlane(built.dashboard, 3.85, 3.85 / SCREEN_ASPECT, -0.22, 0.34, -1.48, -0.02);
placePlane(built.boothSign, 2.22, 2.22 / BOOTH_ASPECT, -0.06, -1.58, 0.08, 0);
placePlane(built.speech, 1.46, 1.46 / SPEECH_ASPECT, 1.88, -0.2, 0.24, -0.1);
placeSprite(built.orc, 1.95, 1.95 / ORC_ASPECT, 0, -1.34, 0.28, 0, 1);
placeSprite(built.elf, 1.22, 1.22 / ELF_ASPECT, 2.05, -1.52, 0.46, -0.1, 1);
placeSprite(built.dwarf, 0.98, 0.98 / DWARF_ASPECT, -2.02, -1.54, 0.58, 0.1, 1);
placeFlat(built.boothRing, -0.08, -1.92, 0.92, 0.92, 0);
}
function placeMobile(built) {
placePlane(built.bg, 10.7, 10.7 / BACKDROP_ASPECT, 0, 0.16, -4.0, 0);
placePlane(built.title, 2.8, 0.48, 0, 1.02, -1.24, 0);
placePlane(built.dashboard, 3.05, 3.05 / SCREEN_ASPECT, 0, 0.5, -1.42, 0);
placePlane(built.boothSign, 1.84, 1.84 / BOOTH_ASPECT, -0.12, -1.24, 0.2, 0);
placePlane(built.speech, 1.08, 1.08 / SPEECH_ASPECT, 0.58, -0.44, 0.38, -0.06);
placeSprite(built.orc, 1.68, 1.68 / ORC_ASPECT, -0.18, -1.0, 0.36, 0, 1);
placeSprite(built.elf, 0.84, 0.84 / ELF_ASPECT, 0.84, -1.22, 0.54, -0.06, 1);
placeSprite(built.dwarf, 0.7, 0.7 / DWARF_ASPECT, -0.86, -1.22, 0.62, 0.06, 1);
placeFlat(built.boothRing, -0.2, -1.5, 0.86, 0.68, 0);
}
function placePlane(mesh, width, height, x, y, z, rotY = 0) {
mesh.scale.set(width, height, 1);
mesh.position.set(x, y, z);
mesh.rotation.set(0, rotY, 0);
mesh.userData.baseScale = mesh.scale.clone();
}
function placeSprite(group, width, height, x, y, z, rotY = 0, baseScale = 1) {
const sprite = group.userData.sprite;
sprite.scale.set(width, height, 1);
group.position.set(x, y, z);
group.rotation.set(0, rotY, 0);
group.userData.baseY = y;
group.userData.baseRotY = rotY;
group.userData.baseScale = baseScale;
}
function placeFlat(group, x, y, z, scale, rotY) {
group.position.set(x, y, z);
group.scale.setScalar(scale);
group.rotation.set(0, rotY, 0);
}
function makeAnimatedTexture(width, height, draw) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const texture = new THREE.CanvasTexture(canvas);
prepareTexture(texture);
const update = (time = 0) => {
const data = draw(ctx, width, height, time) || {};
texture.userData.frame = (texture.userData.frame || 0) + 1;
Object.assign(texture.userData, data);
texture.needsUpdate = true;
};
update(0);
return { texture, update };
}
function prepareTexture(texture) {
texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = 8;
}
function drawRound(ctx, x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
function drawHex(ctx, x, y, radius) {
ctx.beginPath();
for (let i = 0; i < 6; i += 1) {
const angle = Math.PI / 6 + i * Math.PI / 3;
const px = x + Math.cos(angle) * radius;
const py = y + Math.sin(angle) * radius;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
}
function fitText(ctx, text, x, y, maxWidth, size, align = 'center') {
let fontSize = size;
const family = ctx.font.replace(/^[^ ]+ /, '');
ctx.textAlign = align;
while (fontSize > 9) {
ctx.font = `${fontSize}px ${family}`;
if (ctx.measureText(text).width <= maxWidth) break;
fontSize -= 1;
}
ctx.fillText(text, x, y);
}
function routeTitle(route) {
return {
agents: 'Browse agent mercenaries.',
booth: 'Enter your private booth.',
contracts: 'Contract every run.',
signin: 'Claim a tavern pass.',
admin: 'Quartermaster control.',
}[route] || 'Hire agents like mercenaries.';
}
function positiveModulo(value, modulo) {
return ((value % modulo) + modulo) % modulo;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function round(value) {
return Math.round(value * 1000) / 1000;
}
function countMeshes(root) {
let count = 0;
root.traverse((child) => {
if (child.isMesh || child.isPoints) count += 1;
});
return count;
}
function publishStageDebug(metrics, tracked, camera) {
if (typeof window === 'undefined') return;
const placements = Object.fromEntries(
Object.entries(tracked).map(([name, object]) => {
const bounds = projectedBounds(object, camera);
const visible = bounds.left >= -1.08 && bounds.right <= 1.08 && bounds.bottom >= -1.08 && bounds.top <= 1.08;
return [name, { bounds, visible }];
}),
);
window.__AMERC_STAGE = {
version: RELEASE,
release: RELEASE,
...metrics,
placements,
allVisible: Object.values(placements).every((item) => item.visible),
};
}
function projectedBounds(object, camera) {
object.updateWorldMatrix(true, true);
const box = new THREE.Box3().setFromObject(object);
if (box.isEmpty()) return { left: 0, right: 0, top: 0, bottom: 0 };
const points = [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z),
].map((point) => point.project(camera));
return {
left: round(Math.min(...points.map((point) => point.x))),
right: round(Math.max(...points.map((point) => point.x))),
top: round(Math.max(...points.map((point) => point.y))),
bottom: round(Math.min(...points.map((point) => point.y))),
};
}
function disposeScene(scene) {
scene.traverse((object) => {
if (object.geometry) object.geometry.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => {
if (material.map) material.map.dispose();
material.dispose?.();
});
});
}

141
src/Whiteboard.jsx Normal file
View File

@ -0,0 +1,141 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api, fileUrl } from './api.js';
import Netdisk, { FilePreview } from './Netdisk.jsx';
const COLORS = ['#27d8ff', '#36f0b0', '#f2b85f', '#9d73ff', '#ff7a9c'];
const uid = () => `n${Math.random().toString(36).slice(2, 8)}`;
// Mind-map whiteboard. Nodes are draggable; nodes can link to a netdisk file,
// which is previewed (and openable to edit) in the side panel.
export default function Whiteboard({ boardId, onClose }) {
const [name, setName] = useState('');
const [nodes, setNodes] = useState([]);
const [edges, setEdges] = useState([]);
const [sel, setSel] = useState(null);
const [connectFrom, setConnectFrom] = useState(null);
const [picking, setPicking] = useState(false);
const [previewFile, setPreviewFile] = useState(null);
const [dirty, setDirty] = useState(false);
const [saved, setSaved] = useState('');
const svgRef = useRef(null);
const drag = useRef(null);
useEffect(() => {
api(`/boards/${boardId}`).then((d) => {
setName(d.board.name);
let data = {}; try { data = JSON.parse(d.board.data || '{}'); } catch {}
setNodes(data.nodes || []); setEdges(data.edges || []);
}).catch(() => {});
}, [boardId]);
const save = useCallback(async () => {
const links = nodes.filter((n) => n.link).map((n) => ({ nodeId: n.id, ...n.link }));
await api(`/boards/${boardId}`, { method: 'PATCH', body: { name, data: JSON.stringify({ nodes, edges }), links } });
setDirty(false); setSaved('saved ' + new Date().toLocaleTimeString()); setTimeout(() => setSaved(''), 2500);
}, [boardId, name, nodes, edges]);
// autosave when dirty
useEffect(() => { if (!dirty) return; const t = setTimeout(save, 1200); return () => clearTimeout(t); }, [dirty, save]);
const mutate = (fn) => { fn(); setDirty(true); };
const svgPoint = (e) => {
const r = svgRef.current.getBoundingClientRect();
return { x: e.clientX - r.left + svgRef.current.parentNode.scrollLeft, y: e.clientY - r.top + svgRef.current.parentNode.scrollTop };
};
const addNode = (e) => {
if (e.target !== svgRef.current) return;
const p = svgPoint(e);
const n = { id: uid(), x: p.x, y: p.y, text: 'idea', color: COLORS[nodes.length % COLORS.length] };
mutate(() => setNodes((ns) => [...ns, n])); setSel(n.id);
};
const onNodeDown = (e, n) => {
e.stopPropagation();
if (connectFrom && connectFrom !== n.id) {
mutate(() => setEdges((es) => es.some((x) => x.from === connectFrom && x.to === n.id) ? es : [...es, { from: connectFrom, to: n.id }]));
setConnectFrom(null); return;
}
setSel(n.id);
const p = svgPoint(e);
drag.current = { id: n.id, dx: p.x - n.x, dy: p.y - n.y, moved: false };
};
const onMove = (e) => {
if (!drag.current) return;
const p = svgPoint(e); drag.current.moved = true;
setNodes((ns) => ns.map((n) => n.id === drag.current.id ? { ...n, x: p.x - drag.current.dx, y: p.y - drag.current.dy } : n));
};
const onUp = () => { if (drag.current?.moved) setDirty(true); drag.current = null; };
const selNode = nodes.find((n) => n.id === sel) || null;
const setSelField = (k, v) => mutate(() => setNodes((ns) => ns.map((n) => n.id === sel ? { ...n, [k]: v } : n)));
const delSel = () => { if (!sel) return; mutate(() => { setNodes((ns) => ns.filter((n) => n.id !== sel)); setEdges((es) => es.filter((x) => x.from !== sel && x.to !== sel)); }); setSel(null); };
const linkFile = (f) => { setSelField('link', { type: 'file', id: f.id, label: f.name, mime: f.mime, kind: f.kind }); setPicking(false); };
const openLinked = (n) => { if (n.link?.type === 'file') api(`/files/${n.link.id}`).then((d) => setPreviewFile(d.file)).catch(() => {}); };
return (
<div className="wb">
<div className="wb-toolbar">
<button className="cs-btn" onClick={onClose}> Boards</button>
<input className="wb-name" value={name} onChange={(e) => { setName(e.target.value); setDirty(true); }} />
<span className="wb-hint">double-click canvas = new node · drag to move</span>
<button className={`cs-btn${connectFrom ? ' cs-primary' : ''}`} onClick={() => setConnectFrom(connectFrom ? null : sel)} disabled={!sel && !connectFrom}>{connectFrom ? 'click target…' : 'Connect'}</button>
<button className="cs-btn" onClick={delSel} disabled={!sel}>Delete</button>
<button className="cs-btn" onClick={() => setPicking(true)} disabled={!sel}>Link file</button>
<button className="cs-btn cs-primary" onClick={save}>Save</button>
<span className="wb-saved">{saved || (dirty ? 'unsaved…' : '')}</span>
</div>
<div className="wb-body">
<div className="wb-canvas-wrap">
<svg ref={svgRef} className="wb-canvas" width="2400" height="1600" onDoubleClick={addNode} onMouseMove={onMove} onMouseUp={onUp} onMouseLeave={onUp}>
{edges.map((ed, i) => {
const a = nodes.find((n) => n.id === ed.from), b = nodes.find((n) => n.id === ed.to);
if (!a || !b) return null;
return <line key={i} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#3a4a6a" strokeWidth="2" />;
})}
{nodes.map((n) => (
<g key={n.id} transform={`translate(${n.x},${n.y})`} onMouseDown={(e) => onNodeDown(e, n)} onDoubleClick={(e) => { e.stopPropagation(); openLinked(n); }} style={{ cursor: 'grab' }}>
<rect x={-Math.max(46, n.text.length * 4.6)} y={-20} width={Math.max(92, n.text.length * 9.2)} height={40} rx={10}
fill="#101626" stroke={sel === n.id ? '#fff' : n.color} strokeWidth={sel === n.id ? 3 : 2} />
<text textAnchor="middle" y={n.link ? -2 : 5} fill="#eaf2ff" fontSize="13" style={{ pointerEvents: 'none' }}>{n.text}</text>
{n.link && <text textAnchor="middle" y={13} fill={n.color} fontSize="9" style={{ pointerEvents: 'none' }}>🔗 {n.link.label.slice(0, 22)}</text>}
</g>
))}
</svg>
</div>
<aside className="wb-side">
{selNode ? (
<>
<h4>Node</h4>
<label className="cs-field"><span>Text</span><input value={selNode.text} onChange={(e) => setSelField('text', e.target.value)} /></label>
<label className="cs-field"><span>Color</span>
<div className="wb-colors">{COLORS.map((c) => <button key={c} className={selNode.color === c ? 'on' : ''} style={{ background: c }} onClick={() => setSelField('color', c)} />)}</div>
</label>
{selNode.link ? (
<div className="wb-link">
<span>🔗 {selNode.link.label}</span>
<button className="cs-btn" onClick={() => openLinked(selNode)}>Preview / edit</button>
<button className="cs-btn" onClick={() => setSelField('link', undefined)}>Unlink</button>
</div>
) : <button className="cs-btn" onClick={() => setPicking(true)}>Link netdisk file</button>}
</>
) : <p className="cs-hint">Select a node, or double-click the canvas to add one. Link nodes to netdisk files to build a visual map of your docs and assets.</p>}
</aside>
</div>
{picking && (
<div className="cs-modal" onClick={() => setPicking(false)}>
<div className="cs-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="cs-modal-head"><strong>Link a netdisk file to this node</strong><button onClick={() => setPicking(false)}></button></div>
<Netdisk compact onPick={linkFile} />
</div>
</div>
)}
{previewFile && (
<div className="cs-modal" onClick={() => setPreviewFile(null)}>
<div className="cs-modal-box cs-modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="cs-modal-head"><strong>{previewFile.name}</strong><button onClick={() => setPreviewFile(null)}></button></div>
<FilePreview file={previewFile} onChange={() => {}} />
</div>
</div>
)}
</div>
);
}

29
src/api.js Normal file
View File

@ -0,0 +1,29 @@
// Shared amerc API client. Same-origin /api on every *.amerc.ai subdomain
// (each nginx vhost proxies /api -> the backend; session cookie is Domain=.amerc.ai).
export const API = '/api';
export async function api(path, { method = 'GET', body, raw = false } = {}) {
const res = await fetch(API + path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
});
if (raw) return res;
let data = {};
try { data = await res.json(); } catch { /* ignore */ }
if (!res.ok || data.ok === false) throw new Error(data.error || `request failed (${res.status})`);
return data;
}
export const fileUrl = (id) => `${API}/files/${id}/raw`;
// Read a File object as base64 (for netdisk uploads).
export function fileToBase64(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result).split(',')[1] || '');
r.onerror = reject;
r.readAsDataURL(file);
});
}

21
src/main.jsx Normal file
View File

@ -0,0 +1,21 @@
import React, { Suspense, lazy } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
// One SPA, served from every *.amerc.ai docroot; pick the app by hostname.
const host = window.location.hostname;
const DocsApp = lazy(() => import('./DocsApp.jsx'));
const PmApp = lazy(() => import('./PmApp.jsx'));
const Scene2DApp = lazy(() => import('./Scene2D.jsx'));
let App = Scene2DApp;
if (host.startsWith('docs.')) App = DocsApp;
else if (host.startsWith('pm.')) App = PmApp;
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Suspense fallback={<div style={{ color: '#9fb0d0', fontFamily: 'monospace', padding: 24 }}>Loading amerc</div>}>
<App />
</Suspense>
</React.StrictMode>,
);

907
src/styles.css Normal file
View File

@ -0,0 +1,907 @@
:root {
color-scheme: dark;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--cream: #fff0d7;
--gold: #f7bc5d;
--cyan: #42dcff;
--wood-a: #8b4a22;
--wood-b: #170704;
background: #050303;
color: var(--cream);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
width: 100%;
min-width: 320px;
height: 100%;
margin: 0;
overflow: hidden;
background: #050303;
}
button {
border: 0;
color: inherit;
font: inherit;
}
.amerc-app,
.tavern-stage {
position: fixed;
inset: 0;
overflow: hidden;
}
.tavern-stage {
z-index: 0;
height: 100svh;
background: #050303;
}
.tavern-stage canvas,
.webgl-fallback {
display: block;
width: 100%;
height: 100%;
}
.tavern-stage canvas {
cursor: grab;
touch-action: none;
}
.webgl-fallback {
background:
radial-gradient(circle at 50% 28%, rgba(44, 214, 255, 0.22), transparent 30%),
linear-gradient(180deg, #170c07, #050303 70%);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.wood-topbar {
position: fixed;
z-index: 20;
top: 0;
left: 0;
display: grid;
width: 100%;
min-height: 74px;
grid-template-columns: minmax(220px, 306px) 1fr auto;
align-items: start;
padding: 7px 28px 0 12px;
pointer-events: none;
}
.wood-topbar::before {
position: absolute;
top: 16px;
right: 28px;
left: 224px;
height: 50px;
border: 3px solid #3f1b0b;
border-radius: 7px;
background:
radial-gradient(ellipse at 18% 40%, rgba(31, 9, 3, 0.54) 0 9px, rgba(144, 70, 26, 0.18) 10px 18px, transparent 20px),
radial-gradient(ellipse at 66% 58%, rgba(22, 7, 2, 0.5) 0 7px, rgba(167, 86, 32, 0.18) 8px 17px, transparent 19px),
repeating-linear-gradient(0deg, rgba(255, 226, 160, 0.09) 0 2px, transparent 2px 15px),
repeating-linear-gradient(90deg, rgba(18, 6, 2, 0.18) 0 4px, transparent 4px 112px),
linear-gradient(180deg, #8a471f 0%, #5a2a12 36%, #2d1207 72%, #130602 100%);
box-shadow:
inset 0 4px 0 rgba(255, 224, 150, 0.24),
inset 0 -7px 0 rgba(0, 0, 0, 0.54),
inset 0 0 0 1px rgba(255, 206, 112, 0.13),
0 16px 32px rgba(0, 0, 0, 0.62),
0 2px 0 rgba(255, 210, 126, 0.1);
content: "";
pointer-events: auto;
}
.wood-topbar::after {
position: absolute;
top: 5px;
left: 50%;
width: 48px;
height: 64px;
border: 3px solid #4b1e0b;
border-radius: 5px;
background:
radial-gradient(circle at 50% 22%, #8ff5ff 0 12px, #006cff 13px 20px, transparent 21px),
radial-gradient(ellipse at 36% 68%, rgba(18, 5, 1, 0.42), transparent 24px),
linear-gradient(180deg, #8a451d, #2f1307);
box-shadow:
inset 0 2px 0 rgba(255, 220, 145, 0.2),
0 0 22px rgba(32, 170, 255, 0.42),
0 10px 28px rgba(0, 0, 0, 0.48);
content: "";
transform: translateX(-50%) rotate(45deg);
pointer-events: none;
}
.logo-plaque,
.wood-nav,
.wood-menu,
.wood-mobile {
position: relative;
pointer-events: auto;
}
.logo-plaque {
display: grid;
width: min(286px, calc(100vw - 84px));
min-height: 76px;
place-items: center;
padding: 6px 18px 9px;
border: 4px solid #3f1a09;
border-radius: 9px;
background:
radial-gradient(ellipse at 24% 42%, rgba(22, 6, 2, 0.52) 0 10px, rgba(143, 70, 25, 0.22) 11px 21px, transparent 23px),
radial-gradient(ellipse at 72% 63%, rgba(20, 6, 2, 0.5) 0 8px, rgba(178, 92, 34, 0.18) 9px 20px, transparent 22px),
repeating-linear-gradient(0deg, rgba(255, 228, 166, 0.11) 0 2px, transparent 2px 17px),
repeating-linear-gradient(90deg, rgba(15, 5, 1, 0.18) 0 4px, transparent 4px 92px),
linear-gradient(180deg, #8c4a22 0%, #5c2a12 42%, #2a1107 74%, #120602 100%);
box-shadow:
inset 0 5px 0 rgba(255, 228, 156, 0.26),
inset 0 -8px 0 rgba(0, 0, 0, 0.46),
inset 0 0 0 1px rgba(255, 202, 110, 0.14),
0 18px 34px rgba(0, 0, 0, 0.48);
cursor: pointer;
}
.logo-plaque::before,
.logo-plaque::after,
.wood-nav::before,
.wood-nav::after {
position: absolute;
width: 28px;
height: 28px;
border: 2px solid #331305;
border-radius: 4px;
background:
radial-gradient(circle at 50% 35%, rgba(255, 200, 104, 0.25), transparent 42%),
linear-gradient(180deg, #a15a24, #4c210e);
box-shadow:
inset 0 2px 0 rgba(255, 215, 143, 0.24),
0 2px 0 rgba(0, 0, 0, 0.38);
content: "";
}
.logo-plaque::before {
top: -8px;
left: -8px;
}
.logo-plaque::after {
right: -8px;
bottom: -8px;
}
.logo-gem {
position: absolute;
top: -16px;
left: 50%;
width: 38px;
height: 38px;
border: 5px solid #6f3516;
background: linear-gradient(135deg, #8ff4ff, #0059ff 58%, #03246c);
box-shadow:
0 0 20px rgba(32, 180, 255, 0.72),
0 12px 20px rgba(0, 0, 0, 0.4);
transform: translateX(-50%) rotate(45deg);
}
.logo-plaque strong {
color: var(--gold);
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(2.24rem, 4.8vw, 3.72rem);
font-weight: 900;
line-height: 0.82;
text-shadow:
0 4px 0 #4d210c,
0 0 22px rgba(255, 186, 84, 0.35);
}
.logo-plaque small {
color: #ffc96e;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(0.8rem, 1.05vw, 1rem);
font-weight: 800;
text-shadow: 0 2px 0 #4d210c;
}
.logo-plaque em {
margin-top: 1px;
color: rgba(255, 238, 201, 0.82);
font-size: 0.56rem;
font-style: normal;
font-weight: 900;
text-transform: uppercase;
}
.wood-nav {
z-index: 2;
display: flex;
min-height: 50px;
align-items: center;
justify-content: flex-end;
gap: clamp(10px, 2.6vw, 40px);
padding: 14px 32px 0 10px;
}
.wood-nav::before {
top: 13px;
left: 14px;
}
.wood-nav::after {
top: 13px;
right: 2px;
}
.wood-nav button,
.wood-mobile button {
padding: 5px 4px;
background: transparent;
color: #f4e3c5;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(1rem, 1.35vw, 1.38rem);
font-weight: 800;
text-shadow: 0 2px 0 #1b0b05;
cursor: pointer;
}
.wood-nav button:hover,
.wood-nav button.active,
.wood-mobile button:hover,
.wood-mobile button.active {
color: var(--cyan);
text-shadow:
0 2px 0 #06131c,
0 0 16px rgba(53, 213, 255, 0.72);
}
.quartermaster-link {
display: none;
}
.wood-menu {
display: none;
width: 44px;
height: 44px;
place-items: center;
margin-top: 28px;
border: 2px solid #673018;
border-radius: 7px;
background: linear-gradient(180deg, #6a3518, #251007);
color: #ffe4bb;
box-shadow: inset 0 2px 0 rgba(255, 221, 145, 0.18);
cursor: pointer;
}
.wood-mobile {
position: absolute;
top: 92px;
right: 16px;
display: grid;
min-width: 220px;
gap: 10px;
padding: 16px;
border: 2px solid #5e2b13;
border-radius: 8px;
background:
repeating-linear-gradient(0deg, rgba(255, 222, 159, 0.06) 0 2px, transparent 2px 15px),
linear-gradient(180deg, #522613, #180b05);
box-shadow: 0 20px 38px rgba(0, 0, 0, 0.54);
}
@media (max-width: 1040px) {
.wood-topbar {
grid-template-columns: minmax(210px, 278px) 1fr auto;
padding-right: 18px;
}
.wood-topbar::before {
left: 218px;
}
.wood-nav {
gap: 16px;
padding-right: 18px;
}
}
@media (max-width: 820px) {
.wood-topbar {
min-height: 88px;
grid-template-columns: 1fr auto;
padding: 8px 10px 0;
}
.wood-topbar::before,
.wood-topbar::after,
.wood-nav {
display: none;
}
.logo-plaque {
width: min(228px, calc(100vw - 72px));
min-height: 72px;
padding: 8px 14px 12px;
}
.logo-plaque strong {
font-size: clamp(2.08rem, 10vw, 2.85rem);
}
.logo-plaque small {
font-size: 0.84rem;
}
.logo-plaque em {
font-size: 0.62rem;
}
.logo-gem {
top: -13px;
width: 32px;
height: 32px;
border-width: 4px;
}
.wood-menu {
display: grid;
margin-top: 20px;
}
.quartermaster-link {
display: inline-block;
}
}
@media (max-width: 430px) {
.wood-mobile {
right: 10px;
left: 10px;
}
}
.gemhall-app .wood-topbar {
min-height: 96px;
grid-template-columns: minmax(238px, 330px) 1fr auto;
padding: 14px 28px 0 14px;
}
.gemhall-app .wood-topbar::before {
top: 21px;
right: 26px;
left: 262px;
height: 58px;
border: 2px solid #2a0e04;
border-radius: 6px;
background:
radial-gradient(ellipse at 12% 28%, rgba(255, 187, 89, 0.2) 0 6px, rgba(42, 13, 4, 0.62) 7px 16px, transparent 18px),
radial-gradient(ellipse at 39% 72%, rgba(255, 181, 73, 0.14) 0 5px, rgba(28, 7, 2, 0.62) 6px 14px, transparent 16px),
radial-gradient(ellipse at 78% 36%, rgba(255, 205, 119, 0.15) 0 7px, rgba(24, 7, 2, 0.58) 8px 19px, transparent 21px),
repeating-linear-gradient(0deg, rgba(255, 219, 141, 0.14) 0 2px, transparent 2px 12px),
repeating-linear-gradient(90deg, rgba(12, 4, 1, 0.26) 0 3px, transparent 3px 84px),
linear-gradient(180deg, #a45b27 0%, #743616 34%, #321207 72%, #0c0301 100%);
box-shadow:
inset 0 3px 0 rgba(255, 227, 160, 0.3),
inset 0 -6px 0 rgba(0, 0, 0, 0.58),
inset 0 0 0 1px rgba(255, 207, 112, 0.16),
0 15px 28px rgba(0, 0, 0, 0.66);
}
.gemhall-app .wood-topbar::after {
top: 5px;
width: 46px;
height: 66px;
background:
radial-gradient(circle at 50% 22%, #b5fbff 0 10px, #0079ff 11px 18px, transparent 19px),
linear-gradient(180deg, #a75c27, #311307);
}
.gemhall-app .logo-plaque {
width: min(318px, calc(100vw - 78px));
min-height: 102px;
padding: 10px 18px 14px;
border: 3px solid #2a0e04;
background:
radial-gradient(ellipse at 17% 38%, rgba(255, 189, 89, 0.2) 0 7px, rgba(35, 10, 3, 0.66) 8px 19px, transparent 21px),
radial-gradient(ellipse at 78% 64%, rgba(255, 204, 114, 0.16) 0 7px, rgba(29, 8, 2, 0.62) 8px 20px, transparent 22px),
repeating-linear-gradient(0deg, rgba(255, 228, 159, 0.16) 0 2px, transparent 2px 13px),
repeating-linear-gradient(90deg, rgba(15, 5, 1, 0.24) 0 4px, transparent 4px 78px),
linear-gradient(180deg, #a65b29 0%, #713415 40%, #2b1006 74%, #0b0301 100%);
box-shadow:
inset 0 4px 0 rgba(255, 233, 168, 0.32),
inset 0 -7px 0 rgba(0, 0, 0, 0.52),
0 16px 32px rgba(0, 0, 0, 0.58);
}
.gemhall-app .logo-plaque strong {
font-size: clamp(2.9rem, 5.8vw, 4.85rem);
}
.gemhall-app .wood-nav {
min-height: 52px;
gap: clamp(12px, 2.35vw, 34px);
padding-top: 22px;
}
.gemhall-app .wood-menu {
border-color: #2a0e04;
background:
radial-gradient(ellipse at 48% 28%, rgba(255, 202, 115, 0.22), transparent 32%),
linear-gradient(180deg, #8c481e, #271006);
}
@media (max-width: 820px) {
.gemhall-app .wood-topbar {
min-height: 74px;
padding: 7px 8px 0;
}
.gemhall-app .logo-plaque {
width: min(210px, calc(100vw - 66px));
min-height: 66px;
padding: 6px 12px 10px;
}
.gemhall-app .logo-plaque strong {
font-size: clamp(2.05rem, 9.4vw, 2.85rem);
}
.gemhall-app .logo-plaque small {
font-size: 0.78rem;
}
.gemhall-app .logo-plaque em {
font-size: 0.56rem;
}
.gemhall-app .wood-menu {
width: 42px;
height: 42px;
margin-top: 15px;
}
}
/* ===================================================================== */
/* 0.18.0-pixel — 2D Starbound-style tavern */
/* ===================================================================== */
.px-app {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
font-family: "Press Start 2P", ui-monospace, "Courier New", monospace;
background: #07060c;
overflow: hidden;
}
.px-app img { image-rendering: pixelated; image-rendering: crisp-edges; }
/* ---- top bar ---- */
.px-topbar {
position: relative;
z-index: 40;
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
background: linear-gradient(180deg, #2a1608, #140803);
border-bottom: 4px solid #000;
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.6), inset 0 2px 0 #6b3a1c;
}
.px-logo {
display: flex;
align-items: baseline;
gap: 8px;
background: none;
cursor: pointer;
color: var(--gold);
}
.px-logo-gem {
width: 14px; height: 14px;
background: var(--cyan);
box-shadow: 0 0 0 3px #07334a, 2px 2px 0 #000;
transform: rotate(45deg);
align-self: center;
}
.px-logo strong { font-size: 18px; letter-spacing: 1px; color: var(--cream); text-shadow: 2px 2px 0 #000; }
.px-logo small { font-size: 7px; color: #c79a5c; letter-spacing: 1px; }
.px-logo em { font-size: 7px; color: #6f5736; font-style: normal; }
.px-nav { display: flex; gap: 8px; margin-left: auto; flex-wrap: wrap; }
.px-nav-btn {
font-family: inherit;
font-size: 9px;
padding: 9px 11px;
color: var(--cream);
background: #3a2110;
border: 2px solid #000;
box-shadow: inset -2px -2px 0 #1c0f06, inset 2px 2px 0 #6b3a1c, 2px 2px 0 #000;
cursor: pointer;
letter-spacing: 1px;
}
.px-nav-btn:hover { background: #50300f; color: #fff; }
.px-nav-btn.active { background: var(--gold); color: #2a1303; box-shadow: inset -2px -2px 0 #b07a26, inset 2px 2px 0 #ffe6a8, 2px 2px 0 #000; }
.px-nav-btn.quartermaster { color: var(--cyan); }
.px-nav-btn.quartermaster.active { color: #06222f; background: var(--cyan); }
.px-menu { display: none; margin-left: auto; color: var(--cream); background: #3a2110; border: 2px solid #000; padding: 8px; cursor: pointer; }
.px-mobile { display: none; }
/* ---- stage ---- */
.px-stage { position: relative; flex: 1; overflow: hidden; cursor: crosshair; }
.px-bg {
position: absolute;
inset: -6%;
background-image: url('/scene2d/bg.png');
background-size: cover;
background-position: center 38%;
image-rendering: pixelated;
filter: saturate(1.05) brightness(0.82) contrast(1.08);
will-change: transform;
}
.px-bg-haze {
position: absolute; inset: 0;
background:
radial-gradient(120% 80% at 66% 40%, rgba(255, 159, 56, 0.28), transparent 55%),
radial-gradient(90% 70% at 30% 55%, rgba(39, 216, 255, 0.16), transparent 60%);
mix-blend-mode: screen;
pointer-events: none;
}
.px-vignette {
position: absolute; inset: 0; pointer-events: none;
background: radial-gradient(130% 100% at 50% 42%, transparent 42%, rgba(5, 3, 8, 0.85) 100%);
}
/* ---- embers ---- */
.px-embers { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.px-embers span {
position: absolute;
bottom: -10px;
background: #ffb451;
box-shadow: 0 0 6px 1px rgba(255, 180, 81, 0.8);
opacity: 0;
animation-name: px-rise;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes px-rise {
0% { transform: translateY(0) translateX(0); opacity: 0; }
12% { opacity: 0.9; }
80% { opacity: 0.5; }
100% { transform: translateY(-78vh) translateX(18px); opacity: 0; }
}
/* ---- floor + characters ---- */
.px-floor {
position: absolute;
left: 0; right: 0;
bottom: 6%;
height: 0;
will-change: transform;
}
.px-char {
position: absolute;
bottom: 0;
transform: translateX(-50%);
background: none;
cursor: pointer;
padding: 0;
filter: drop-shadow(3px 4px 0 rgba(0, 0, 0, 0.55));
transition: filter 0.15s;
}
.px-char img {
display: block;
animation: px-bob var(--bob, 3.6s) ease-in-out infinite;
}
.px-char:hover { filter: drop-shadow(0 0 10px rgba(255, 200, 120, 0.7)) drop-shadow(3px 4px 0 rgba(0, 0, 0, 0.55)); }
.px-char.here img { animation: px-bob var(--bob, 3.6s) ease-in-out infinite, px-glow 1.6s ease-in-out infinite; }
@keyframes px-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
@keyframes px-glow {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.18); }
}
.px-char-shadow {
position: absolute;
left: 50%; bottom: -6px;
width: 60%; height: 12px;
transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.55), transparent 70%);
pointer-events: none;
}
.px-speech {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 230px;
padding: 8px 10px;
font-size: 8px;
line-height: 1.7;
color: #1c1206;
background: var(--cream);
border: 2px solid #000;
box-shadow: 3px 3px 0 #000;
text-align: center;
}
.px-speech strong { color: #7a3d0c; }
.px-speech em { display: block; margin-top: 4px; font-style: normal; color: #3a2a14; }
.px-speech::after {
content: '';
position: absolute; top: 100%; left: 50%;
transform: translateX(-50%);
border: 7px solid transparent;
border-top-color: #000;
}
/* ---- route board ---- */
.px-board {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
width: min(340px, 42vw);
padding: 18px 18px 16px;
z-index: 20;
color: var(--cream);
background: linear-gradient(180deg, rgba(20, 14, 30, 0.95), rgba(10, 7, 16, 0.96));
border: 3px solid #000;
box-shadow: inset 0 0 0 2px var(--tint), inset 0 0 24px rgba(0, 0, 0, 0.7), 5px 5px 0 #000;
animation: px-board-in 0.25s ease-out;
}
@keyframes px-board-in {
from { opacity: 0; transform: translateY(-50%) translateX(-12px); }
to { opacity: 1; transform: translateY(-50%) translateX(0); }
}
.px-board-rivets {
position: absolute; inset: 6px; pointer-events: none;
background-image: radial-gradient(circle, var(--tint) 1.5px, transparent 2px);
background-position: 0 0, 100% 0, 0 100%, 100% 100%;
background-size: 8px 8px;
background-repeat: no-repeat;
opacity: 0.5;
}
.px-board-tag {
display: inline-block;
font-size: 7px;
letter-spacing: 2px;
padding: 4px 6px;
color: #07060c;
background: var(--tint);
}
.px-board-head h2 { margin: 10px 0 6px; font-size: 14px; color: #fff; text-shadow: 2px 2px 0 #000; line-height: 1.4; }
.px-board-head p { margin: 0 0 12px; font-size: 8px; line-height: 1.8; color: #b9a6cf; }
.px-board-rows { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.px-board-rows li { display: flex; align-items: center; gap: 8px; font-size: 8px; line-height: 1.7; color: var(--cream); }
.px-bullet { flex: none; width: 7px; height: 7px; background: var(--tint); box-shadow: 1px 1px 0 #000; transform: rotate(45deg); }
.px-board-stats { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.px-stat { font-size: 7px; padding: 5px 6px; color: var(--tint); border: 1px solid var(--tint); background: rgba(0, 0, 0, 0.4); }
.px-board-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.px-action {
font-family: inherit;
font-size: 7px;
padding: 7px 8px;
color: var(--cream);
background: #2a1c12;
border: 2px solid #000;
box-shadow: inset -1px -1px 0 #140b06, inset 1px 1px 0 #4a2f18, 2px 2px 0 #000;
cursor: pointer;
letter-spacing: 1px;
}
.px-action:hover { background: var(--tint); color: #07060c; }
/* ---- mobile ---- */
@media (max-width: 760px) {
.px-nav { display: none; }
.px-menu { display: inline-flex; }
.px-mobile {
display: flex; flex-direction: column; gap: 6px;
position: absolute; top: 100%; right: 8px;
padding: 8px; background: #1a0d05; border: 3px solid #000; z-index: 50;
}
.px-board { left: 50%; transform: translate(-50%, 0); top: auto; bottom: 12px; width: min(94vw, 420px); }
@keyframes px-board-in { from { opacity: 0; } to { opacity: 1; } }
.px-char img { height: auto; }
}
/* ---- auth + admin (0.18 backend wiring) ---- */
.px-nav-user { color: #36f0b0; }
.px-board-wide { width: min(680px, 88vw); left: 50%; transform: translate(-50%, -50%); }
@keyframes px-board-in-wide { from { opacity: 0; } to { opacity: 1; } }
.px-auth { display: flex; flex-direction: column; gap: 10px; }
.px-auth-tabs { display: flex; gap: 6px; margin-bottom: 4px; }
.px-auth-tabs button {
flex: 1; font-family: inherit; font-size: 9px; padding: 8px; cursor: pointer; letter-spacing: 1px;
color: var(--cream); background: #2a1c12; border: 2px solid #000; box-shadow: inset 1px 1px 0 #4a2f18;
}
.px-auth-tabs button.active { background: var(--tint); color: #07060c; }
.px-field { display: flex; flex-direction: column; gap: 5px; font-size: 7px; color: #b9a6cf; letter-spacing: 1px; }
.px-field input {
font-family: inherit; font-size: 9px; padding: 9px; color: var(--cream);
background: #0c0814; border: 2px solid #000; box-shadow: inset 1px 1px 0 #251a3a;
}
.px-field input:focus { outline: none; box-shadow: inset 0 0 0 2px var(--tint); }
.px-auth-submit { align-self: stretch; text-align: center; padding: 10px; font-size: 9px; }
.px-auth-err { color: #ff7a7a; font-size: 8px; line-height: 1.6; margin: 0; }
.px-auth-hint { color: #6f5b8c; font-size: 7px; line-height: 1.7; margin: 2px 0 0; }
.px-auth-hi { font-size: 9px; color: var(--cream); line-height: 1.7; margin: 0; }
.px-auth-hi em { color: #36f0b0; font-style: normal; }
.px-auth-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.px-admin { display: flex; flex-direction: column; gap: 10px; }
.px-admin-gate { text-align: center; padding: 12px 0; }
.px-admin-gate h3 { font-size: 12px; color: #ff9f9f; margin: 0 0 8px; }
.px-admin-gate p { font-size: 8px; color: #b9a6cf; margin: 0 0 12px; line-height: 1.7; }
.px-admin-tabs { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.px-admin-tabs button {
font-family: inherit; font-size: 8px; padding: 7px 9px; cursor: pointer; letter-spacing: 1px;
color: var(--cream); background: #2a1c12; border: 2px solid #000;
}
.px-admin-tabs button.active { background: var(--tint); color: #07060c; }
.px-admin-who { margin-left: auto; font-size: 7px; color: #36f0b0; }
.px-admin-table { display: flex; flex-direction: column; border: 2px solid #000; background: rgba(0,0,0,0.35); max-height: 38vh; overflow: auto; }
.px-admin-row { display: grid; grid-template-columns: repeat(var(--cols, 5), 1fr) 40px; gap: 6px; align-items: center; padding: 7px 8px; border-bottom: 1px solid #1c1230; font-size: 8px; }
.px-admin-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.px-admin-head { position: sticky; top: 0; background: #1a1030; color: var(--tint); font-size: 7px; letter-spacing: 1px; z-index: 1; }
.px-admin-row select { font-family: inherit; font-size: 8px; background: #0c0814; color: var(--cream); border: 1px solid #000; padding: 3px; }
.px-admin-acts button { color: #ff7a7a; background: none; cursor: pointer; font-size: 10px; padding: 2px 6px; }
.px-admin-empty { color: #6f5b8c; grid-template-columns: 1fr; }
.px-admin-new { display: flex; gap: 6px; flex-wrap: wrap; }
.px-admin-new input { font-family: inherit; font-size: 8px; padding: 7px; flex: 1 1 90px; min-width: 70px; color: var(--cream); background: #0c0814; border: 2px solid #000; }
/* users table = 4 cols, others vary; set per-tab via attribute fallback */
.px-admin-table .px-admin-row { --cols: 4; }
/* ===================================================================== */
/* Console theme — docs.amerc.ai / pm.amerc.ai (operator tools) */
/* ===================================================================== */
.cs-app { position: fixed; inset: 0; display: flex; flex-direction: column;
background: #0a0e17; color: #dce6f5; font-family: Inter, ui-sans-serif, system-ui, sans-serif; --accent: #27d8ff; }
.cs-loading { margin: auto; color: #6c7b96; font-family: ui-monospace, monospace; }
.cs-brand { display: flex; align-items: center; gap: 8px; font-family: "Press Start 2P", monospace; font-size: 15px; color: #fff; }
.cs-brand small { font-family: Inter, sans-serif; font-size: 11px; color: var(--accent); letter-spacing: 1px; }
.cs-gem { width: 12px; height: 12px; background: var(--accent); transform: rotate(45deg); box-shadow: 0 0 0 2px #07273a; }
.cs-top { display: flex; align-items: center; gap: 18px; padding: 12px 18px; background: #0d1320; border-bottom: 1px solid #1b2536; }
.cs-appnav { display: flex; gap: 4px; margin-left: 8px; }
.cs-appnav a { padding: 6px 12px; border-radius: 7px; color: #aab8d0; text-decoration: none; font-size: 13px; }
.cs-appnav a:hover { background: #16203200; background: #162032; color: #fff; }
.cs-appnav a.on { background: var(--accent); color: #061018; font-weight: 600; }
.cs-user { margin-left: auto; display: flex; align-items: center; gap: 10px; font-size: 13px; color: #aab8d0; }
.cs-user button { padding: 6px 12px; border-radius: 7px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 12px; }
.cs-main { flex: 1; min-height: 0; overflow: hidden; display: flex; }
.cs-login-wrap { margin: auto; }
.cs-login { display: flex; flex-direction: column; gap: 12px; width: 320px; padding: 28px; background: #0e1422; border: 1px solid #1f2a3d; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.cs-login .cs-brand { justify-content: center; margin-bottom: 6px; }
.cs-tabs { display: flex; gap: 6px; }
.cs-tabs button { flex: 1; padding: 8px; border-radius: 8px; background: #18213200; background: #182132; color: #aab8d0; cursor: pointer; font-size: 13px; }
.cs-tabs button.on { background: var(--accent); color: #061018; font-weight: 600; }
.cs-login input, .cs-field input, .nd-folder-input, .nd-bar select, .wb-name, .docs-side-top input, .docs-title-input, .docs-folder-input, .pm-nav + * input { font-family: inherit; }
.cs-login input { padding: 11px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 14px; }
.cs-login input:focus, textarea:focus, .nd-text:focus { outline: 2px solid var(--accent); border-color: transparent; }
.cs-err { color: #ff7a8c; font-size: 13px; margin: 0; }
.cs-hint { color: #6c7b96; font-size: 12px; line-height: 1.6; margin: 6px 0; }
.cs-btn { padding: 8px 12px; border-radius: 8px; background: #1b2536; color: #dce6f5; cursor: pointer; font-size: 13px; border: 1px solid #26334a; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
.cs-btn:hover { background: #233044; }
.cs-btn:disabled { opacity: 0.45; cursor: default; }
.cs-primary { background: var(--accent); color: #061018; border-color: transparent; font-weight: 600; }
.cs-primary:hover { filter: brightness(1.08); }
.cs-field { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #8a99b5; margin-bottom: 10px; }
.cs-field input { padding: 9px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-size: 13px; }
.cs-modal { position: fixed; inset: 0; background: rgba(4,7,12,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
.cs-modal-box { width: min(560px, 92vw); max-height: 86vh; overflow: auto; background: #0e1422; border: 1px solid #233044; border-radius: 14px; padding: 16px; }
.cs-modal-wide { width: min(880px, 94vw); }
.cs-modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.cs-modal-head button { background: #1b2536; color: #dce6f5; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
/* docs */
.docs { display: flex; flex: 1; min-height: 0; width: 100%; }
.docs-side { width: 280px; border-right: 1px solid #1b2536; display: flex; flex-direction: column; background: #0c111c; }
.docs-side-top { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid #1b2536; }
.docs-side-top input { flex: 1; padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; }
.docs-tree { overflow: auto; padding: 8px; }
.docs-folder-name { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #5f6e88; margin: 10px 6px 4px; }
.docs-item { display: block; width: 100%; text-align: left; padding: 7px 10px; border-radius: 7px; color: #c3d0e6; cursor: pointer; font-size: 13px; }
.docs-item:hover { background: #141d2e; }
.docs-item.on { background: var(--accent); color: #061018; }
.docs-main { flex: 1; min-width: 0; overflow: auto; padding: 26px 32px; }
.docs-empty { color: #5f6e88; margin-top: 40px; text-align: center; }
.docs-head { display: flex; align-items: center; gap: 12px; }
.docs-head h2 { margin: 0; font-size: 24px; }
.docs-title-input { flex: 1; font-size: 22px; padding: 8px; border-radius: 8px; background: #0a0f1a; border: 1px solid #233044; color: #fff; }
.docs-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; }
.docs-folder-input { padding: 7px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; width: 120px; }
.docs-byline { color: #5f6e88; font-size: 12px; margin: 4px 0 18px; }
.docs-editor { width: 100%; min-height: 60vh; padding: 16px; border-radius: 10px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; font-family: ui-monospace, monospace; font-size: 14px; line-height: 1.6; resize: vertical; }
.docs-render { line-height: 1.7; font-size: 15px; max-width: 760px; }
.docs-render h1, .docs-render h2, .docs-render h3 { color: #fff; margin-top: 1.4em; }
.docs-render a { color: var(--accent); }
.docs-render code { background: #16203200; background: #162032; padding: 2px 6px; border-radius: 5px; font-size: 0.9em; }
.docs-render pre { background: #0a0f1a; padding: 14px; border-radius: 10px; overflow: auto; border: 1px solid #1b2536; }
.docs-render blockquote { border-left: 3px solid var(--accent); margin: 0; padding-left: 14px; color: #aab8d0; }
.docs-render table { border-collapse: collapse; } .docs-render td, .docs-render th { border: 1px solid #233044; padding: 6px 10px; }
/* pm */
.pm { display: flex; flex: 1; min-height: 0; width: 100%; }
.pm-nav { width: 180px; border-right: 1px solid #1b2536; padding: 14px 10px; display: flex; flex-direction: column; gap: 4px; background: #0c111c; }
.pm-nav button { text-align: left; padding: 10px 12px; border-radius: 8px; color: #c3d0e6; cursor: pointer; font-size: 14px; }
.pm-nav button:hover { background: #141d2e; }
.pm-nav button.on { background: var(--accent); color: #061018; font-weight: 600; }
.pm-nav-ext { margin-top: auto; padding: 10px 12px; color: #8a99b5; font-size: 13px; text-decoration: none; }
.pm-body { flex: 1; min-width: 0; overflow: auto; padding: 22px 26px; display: flex; flex-direction: column; }
.pm-cards-head, .pm-disk h2, .pm-portfolio h2 { display: flex; align-items: center; gap: 12px; }
.pm-cards-head { justify-content: space-between; }
.pm-cards-head h2 { margin: 0; }
.pm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin-top: 16px; }
.pm-card { position: relative; padding: 16px; border-radius: 12px; background: #0e1422; border: 1px solid #1f2a3d; cursor: pointer; display: flex; flex-direction: column; gap: 5px; }
.pm-card:hover { border-color: var(--accent); }
.pm-card-ico { font-size: 28px; } .pm-card strong { font-size: 15px; } .pm-card span { font-size: 12px; color: var(--accent); } .pm-card small { font-size: 11px; color: #5f6e88; }
.pm-card-del { position: absolute; top: 8px; right: 8px; background: #1b2536; color: #ff8c9c; border-radius: 6px; padding: 2px 7px; cursor: pointer; }
.pm-portfolio h2 { margin-top: 0; } .pm-port-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-top: 12px; }
.pm-port-row { display: flex; gap: 10px; padding: 9px 0; border-bottom: 1px solid #1b2536; font-size: 14px; } .pm-port-row span { color: #8a99b5; } .pm-port-row em { margin-left: auto; font-style: normal; color: #aab8d0; }
.risk-green { color: #36f0b0 !important; } .risk-amber { color: #f2b85f !important; } .risk-red { color: #ff7a8c !important; }
.pm-disk { flex: 1; display: flex; flex-direction: column; min-height: 0; } .pm-disk h2 { margin: 0 0 12px; }
/* netdisk */
.nd { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.nd-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.nd-bar select, .nd-folder-input { padding: 8px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #eaf2ff; }
.nd-grid { display: flex; gap: 16px; flex: 1; min-height: 0; }
.nd-compact .nd-grid { display: block; }
.nd-list { width: 100%; max-width: 420px; overflow: auto; border: 1px solid #1b2536; border-radius: 10px; }
.nd-compact .nd-list { max-width: none; max-height: 50vh; }
.nd-row { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid #141d2e; cursor: pointer; font-size: 13px; }
.nd-row:hover { background: #111a29; } .nd-row.on { background: #14223a; }
.nd-icon { font-size: 16px; } .nd-name { font-weight: 500; } .nd-meta { margin-left: auto; color: #5f6e88; font-size: 11px; }
.nd-pick { padding: 3px 8px !important; font-size: 11px !important; }
.nd-del { background: none; color: #6c7b96; cursor: pointer; padding: 2px 6px; } .nd-del:hover { color: #ff8c9c; }
.nd-empty, .docs-empty { color: #5f6e88; padding: 20px; text-align: center; font-size: 13px; }
.nd-preview { flex: 1; min-width: 0; border: 1px solid #1b2536; border-radius: 10px; padding: 14px; overflow: auto; }
.nd-preview-empty { color: #5f6e88; display: flex; align-items: center; justify-content: center; }
.nd-preview-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .nd-preview-head span { color: #5f6e88; font-size: 12px; margin-right: auto; }
.nd-img { max-width: 100%; image-rendering: auto; border-radius: 8px; }
.nd-text { width: 100%; min-height: 50vh; background: #0a0f1a; border: 1px solid #233044; border-radius: 8px; color: #eaf2ff; padding: 12px; font-family: ui-monospace, monospace; }
.nd-textview { white-space: pre-wrap; background: #0a0f1a; padding: 12px; border-radius: 8px; font-family: ui-monospace, monospace; font-size: 13px; }
.nd-binary { color: #8a99b5; } .nd-binary a, .nd-textview a { color: var(--accent); }
/* whiteboard */
.wb { display: flex; flex-direction: column; flex: 1; min-height: 0; width: 100%; }
.wb-toolbar { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid #1b2536; flex-wrap: wrap; }
.wb-name { font-size: 16px; font-weight: 600; padding: 6px 10px; border-radius: 7px; background: #0a0f1a; border: 1px solid #233044; color: #fff; }
.wb-hint { color: #5f6e88; font-size: 12px; }
.wb-saved { color: #36f0b0; font-size: 12px; margin-left: auto; }
.wb-body { flex: 1; display: flex; min-height: 0; }
.wb-canvas-wrap { flex: 1; overflow: auto; background: #0a0e17 radial-gradient(circle, #131c2c 1px, transparent 1px); background-size: 26px 26px; }
.wb-canvas { display: block; background: transparent; }
.wb-side { width: 250px; border-left: 1px solid #1b2536; padding: 16px; background: #0c111c; overflow: auto; }
.wb-side h4 { margin: 0 0 12px; }
.wb-colors { display: flex; gap: 6px; } .wb-colors button { width: 22px; height: 22px; border-radius: 6px; cursor: pointer; border: 2px solid transparent; } .wb-colors button.on { border-color: #fff; }
.wb-link { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; font-size: 13px; color: #aab8d0; }
@media (max-width: 720px) {
.docs-side, .pm-nav { width: 150px; }
.nd-grid { flex-direction: column; } .nd-list { max-width: none; }
.cs-appnav { display: none; }
}

20
vite.config.js Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const apiProxy = { '/api': { target: process.env.AMERC_API || 'http://127.0.0.1:5180', changeOrigin: true } };
export default defineConfig({
plugins: [react()],
server: { proxy: apiProxy },
preview: { proxy: apiProxy },
build: {
chunkSizeWarningLimit: 800,
rollupOptions: {
output: {
manualChunks: {
three: ['three'],
},
},
},
},
});